--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e17d9a8cea71c8311e1a07bf299dcd85fd7dd982
Parents : 19e5563
Author : zenith <157907903+RFnexus@users.noreply.github.com>
Date : 2026-05-11T00:51:26-04:00
Initial RRC protocol support and Channels functionality
Changes
7 files changed, 3084 insertions(+), 3 deletions(-)
Diff
diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py
index 62d2086..b0d99e2 100644
--- a/nomadnet/NomadNetworkApp.py
+++ b/nomadnet/NomadNetworkApp.py
@@ -41,6 +41,13 @@ class NomadNetworkApp:
RNS.log("Saving directory...", RNS.LOG_VERBOSE)
self.directory.save_to_disk()
+ if hasattr(self, "rrc") and self.rrc is not None:
+ try:
+ self.rrc.save()
+ self.rrc.shutdown()
+ except Exception:
+ pass
+
if hasattr(self.ui, "restore_ixon"):
if self.ui.restore_ixon:
try:
@@ -301,6 +308,10 @@ class NomadNetworkApp:
self.directory = nomadnet.Directory(self)
+ from nomadnet.RRC import RRCManager
+ self.rrc = RRCManager(self)
+ self.rrc.load()
+
static_peers = []
for static_peer in self.static_peers:
try:
diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py
new file mode 100644
index 0000000..ff3b95b
--- /dev/null
+++ b/nomadnet/RRC.py
@@ -0,0 +1,1069 @@
+import os
+import re
+import time
+import threading
+import hashlib
+from collections import deque
+
+import RNS
+
+from nomadnet.vendor import cbor
+
+
+_MENTION_RE_CACHE = {}
+
+
+def _mention_re(nick):
+ if not isinstance(nick, str) or not nick:
+ return None
+ pat = _MENTION_RE_CACHE.get(nick)
+ if pat is None:
+ pat = re.compile(r"(?<![A-Za-z0-9_])@"+re.escape(nick)+r"(?![A-Za-z0-9_])", re.IGNORECASE)
+ if len(_MENTION_RE_CACHE) > 32:
+ _MENTION_RE_CACHE.clear()
+ _MENTION_RE_CACHE[nick] = pat
+ return pat
+
+# https://github.com/kc1awv/rrcd/blob/main/rrcd/constants.py
+RRC_VERSION = 1
+
+K_V = 0
+K_T = 1
+K_ID = 2
+K_TS = 3
+K_SRC = 4
+K_ROOM = 5
+K_BODY = 6
+K_NICK = 7
+
+T_HELLO = 1
+T_WELCOME = 2
+
+T_JOIN = 10
+T_JOINED = 11
+T_PART = 12
+T_PARTED = 13
+
+T_MSG = 20
+T_NOTICE = 21
+
+T_PING = 30
+T_PONG = 31
+
+T_ERROR = 40
+
+T_RESOURCE_ENVELOPE = 50
+
+B_HELLO_NAME = 0
+B_HELLO_VER = 1
+B_HELLO_CAPS = 2
+
+B_WELCOME_HUB = 0
+B_WELCOME_VER = 1
+B_WELCOME_CAPS = 2
+B_WELCOME_LIMITS = 3
+
+L_MAX_NICK_BYTES = 0
+L_MAX_ROOM_NAME_BYTES = 1
+L_MAX_MSG_BODY_BYTES = 2
+L_MAX_ROOMS_PER_SESSION = 3
+L_RATE_LIMIT_MSGS_PER_MINUTE= 4
+
+CAP_RESOURCE_ENVELOPE = 0
+
+B_RES_ID = 0
+B_RES_KIND = 1
+B_RES_SIZE = 2
+B_RES_SHA256 = 3
+B_RES_ENCODING = 4
+
+RES_KIND_NOTICE = "notice"
+RES_KIND_MOTD = "motd"
+RES_KIND_BLOB = "blob"
+
+DEFAULT_DEST_NAME = "rrc.hub"
+DEFAULT_MAX_NICK_BYTES = 32
+DEFAULT_MAX_ROOM_BYTES = 64
+DEFAULT_MAX_MSG_BYTES = 350
+DEFAULT_MAX_ROOMS = 32
+DEFAULT_RATE_PER_MINUTE = 240
+
+
+
+
+
+
+def _now_ms():
+ return int(time.time()*1000)
+
+
+def _msg_id():
+ return os.urandom(8)
+
+
+def _parse_room_list_notice(text):
+ if not isinstance(text, str):
+ return None
+ stripped = text.strip()
+ if stripped == "No public rooms registered":
+ return {}
+ lines = text.split("\n")
+ if not lines or not lines[0].lstrip().startswith("Registered public rooms"):
+ return None
+ rooms = {}
+ for line in lines[1:]:
+ s = line.strip()
+ if not s:
+ continue
+ if " - " in s:
+ name, topic = s.split(" - ", 1)
+ rooms[name.strip().lower()] = topic.strip() or None
+ else:
+ rooms[s.strip().lstrip("#").lower()] = None
+ return rooms
+
+
+def _make_envelope(msg_type, src, room=None, body=None, nick=None, mid=None, ts=None):
+ env = {
+ K_V: RRC_VERSION,
+ K_T: int(msg_type),
+ K_ID: mid or _msg_id(),
+ K_TS: ts or _now_ms(),
+ K_SRC: src,
+ }
+ if room is not None:
+ env[K_ROOM] = room
+ if body is not None:
+ env[K_BODY] = body
+ if nick is not None and nick != "":
+ env[K_NICK] = nick
+ return env
+
+
+class RRCMessage:
+ def __init__(self, kind, room, src, nick, text, ts):
+ self.kind = kind
+ self.room = room
+ self.src = src
+ self.nick = nick
+ self.text = text
+ self.ts = ts
+ self.mention = False
+
+
+class RRCHub:
+ STATUS_DISCONNECTED = 0
+ STATUS_CONNECTING = 1
+ STATUS_CONNECTED = 2
+ STATUS_FAILED = 3
+
+ def __init__(self, manager, hub_hash, dest_name=None, name=None):
+ self.manager = manager
+ self.hub_hash = hub_hash
+ self.dest_name = dest_name or DEFAULT_DEST_NAME
+ self.name = name or RNS.prettyhexrep(hub_hash)
+
+ self.link = None
+ self.status = RRCHub.STATUS_DISCONNECTED
+ self.status_text = "Disconnected"
+ self.welcomed = False
+ self.hub_name = None
+ self.hub_version = None
+
+ self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES
+ self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES
+ self.max_msg_body_bytes = DEFAULT_MAX_MSG_BYTES
+ self.max_rooms_per_session = DEFAULT_MAX_ROOMS
+ self.rate_limit_msgs_per_minute = DEFAULT_RATE_PER_MINUTE
+
+ self.rooms = set()
+ self.messages = {}
+ self.notices = []
+ self.unread_rooms = set()
+ self.mention_rooms = set()
+ self.members = {}
+ self.nicks = {}
+
+ self.auto_reconnect = False
+ self.auto_list = False
+
+ self._lock = threading.RLock()
+ self._resource_expectations = {}
+ self._sent_ids = deque(maxlen=256)
+
+ self._hello_thread = None
+ self._stop_hello = threading.Event()
+ self._manual_disconnect = False
+ self._reconnect_attempts = 0
+ self._reconnect_timer = None
+ self._pending_pings = {}
+
+ self.available_rooms = {}
+ self._silent_list_pending = 0
+
+ def _log(self, msg, level=None):
+ if level is None:
+ level = RNS.LOG_INFO
+ RNS.log("[RRC "+self.name+"] "+msg, level)
+
+ def add_room(self, room):
+ room_n = self._normalize_room(room)
+ with self._lock:
+ self.rooms.add(room_n)
+ if room_n not in self.messages:
+ self.messages[room_n] = []
+ self.manager.save()
+ self.manager._notify_change(self)
+ return room_n
+
+ def remove_room(self, room):
+ r = self._normalize_room(room)
+ with self._lock:
+ self.rooms.discard(r)
+ self.messages.pop(r, None)
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ self.members.pop(r, None)
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def get_members(self, room):
+ with self._lock:
+ return list(self.members.get(room, set()))
+
+ def display_name_for(self, peer):
+ if not isinstance(peer, (bytes, bytearray)):
+ return "<unknown>"
+ ph = bytes(peer)
+ with self._lock:
+ nick = self.nicks.get(ph)
+ if nick:
+ return nick
+ return ph.hex()[:10]
+
+ def mark_read(self, room):
+ r = self._normalize_room(room)
+ with self._lock:
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ self.manager._notify_change(self)
+
+ def _normalize_room(self, room):
+ r = (room or "").strip().lower()
+ if not r:
+ raise ValueError("room must not be empty")
+ return r
+
+ def _set_status(self, status, text=None):
+ self.status = status
+ if text is not None:
+ self.status_text = text
+ self.manager._notify_change(self)
+
+ def connect(self):
+ with self._lock:
+ if self.status in (RRCHub.STATUS_CONNECTING, RRCHub.STATUS_CONNECTED):
+ return
+ self._manual_disconnect = False
+ if self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ self._set_status(RRCHub.STATUS_CONNECTING, "Connecting")
+
+ t = threading.Thread(target=self._connect_worker, daemon=True)
+ t.start()
+
+ def _connect_worker(self):
+ try:
+ timeout_s = 20.0
+ if not RNS.Transport.has_path(self.hub_hash):
+ RNS.Transport.request_path(self.hub_hash)
+ deadline = time.monotonic() + min(5.0, timeout_s)
+ while time.monotonic() < deadline:
+ if RNS.Transport.has_path(self.hub_hash):
+ break
+ time.sleep(0.1)
+
+ hub_identity = None
+ deadline = time.monotonic() + timeout_s
+ while time.monotonic() < deadline:
+ hub_identity = RNS.Identity.recall(self.hub_hash)
+ if hub_identity is not None:
+ break
+ time.sleep(0.2)
+
+ if hub_identity is None:
+ self._set_status(RRCHub.STATUS_FAILED, "Hub identity unknown")
+ return
+
+ app_name, aspects = RNS.Destination.app_and_aspects_from_name(self.dest_name)
+ hub_dest = RNS.Destination(
+ hub_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ app_name,
+ *aspects,
+ )
+
+ if hub_dest.hash != self.hub_hash:
+ self._set_status(RRCHub.STATUS_FAILED, "Hash/destination name mismatch")
+ return
+
+ self._stop_hello.clear()
+ link = RNS.Link(hub_dest, established_callback=self._on_established, closed_callback=self._on_closed)
+ link.set_packet_callback(lambda data, pkt: self._on_packet(data))
+ with self._lock:
+ self.link = link
+
+ except Exception as e:
+ self._set_status(RRCHub.STATUS_FAILED, "Connect error: "+str(e))
+
+ def _on_established(self, link):
+ try:
+ link.set_resource_strategy(RNS.Link.ACCEPT_APP)
+ link.set_resource_callback(self._resource_advertised)
+ link.set_resource_started_callback(self._resource_advertised)
+ link.set_resource_concluded_callback(self._resource_concluded)
+ except Exception:
+ pass
+
+ try:
+ link.identify(self.manager.identity)
+ except Exception as e:
+ self._log("identify failed: "+str(e), RNS.LOG_ERROR)
+ try: link.teardown()
+ except Exception: pass
+ return
+
+ self._set_status(RRCHub.STATUS_CONNECTING, "Identified, sending HELLO")
+
+ def hello_loop():
+ attempts = 0
+ while not self._stop_hello.is_set() and not self.welcomed and attempts < 5:
+ with self._lock:
+ cur_link = self.link
+ if cur_link is None or cur_link.status != RNS.Link.ACTIVE:
+ return
+ try:
+ self._send_hello(cur_link)
+ except Exception as e:
+ self._log("HELLO send failed: "+str(e), RNS.LOG_ERROR)
+ attempts += 1
+ self._stop_hello.wait(timeout=3.0)
+ if not self.welcomed and not self._stop_hello.is_set():
+ self._set_status(RRCHub.STATUS_FAILED, "WELCOME timeout")
+ try:
+ with self._lock:
+ if self.link is not None:
+ self.link.teardown()
+ except Exception:
+ pass
+
+ self._hello_thread = threading.Thread(target=hello_loop, daemon=True)
+ self._hello_thread.start()
+
+ def _send_hello(self, link):
+ body = {
+ B_HELLO_NAME: "nomadnet",
+ B_HELLO_VER: "0.1",
+ B_HELLO_CAPS: {CAP_RESOURCE_ENVELOPE: True},
+ }
+ env = _make_envelope(T_HELLO, src=self.manager.identity.hash, body=body)
+ nick = self.manager.get_nickname()
+ if nick:
+ env[K_NICK] = nick
+ payload = cbor.encode(env)
+ RNS.Packet(link, payload).send()
+
+ def _on_closed(self, link):
+ self._stop_hello.set()
+ with self._lock:
+ self.link = None
+ self.welcomed = False
+ self.members.clear()
+ self._resource_expectations.clear()
+ should_reconnect = self.auto_reconnect and not self._manual_disconnect
+ self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected")
+ if should_reconnect:
+ self._schedule_reconnect()
+
+ def _schedule_reconnect(self):
+ with self._lock:
+ self._reconnect_attempts += 1
+ backoff = min(60.0, max(1.0, 2.0 ** min(self._reconnect_attempts, 6)))
+ if self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+
+ def fire():
+ with self._lock:
+ self._reconnect_timer = None
+ if self._manual_disconnect or not self.auto_reconnect:
+ return
+ self._set_status(RRCHub.STATUS_CONNECTING, "Reconnecting (attempt "+str(self._reconnect_attempts)+")")
+ self.connect()
+
+ self._reconnect_timer = threading.Timer(backoff, fire)
+ self._reconnect_timer.daemon = True
+ self._reconnect_timer.start()
+ self._set_status(RRCHub.STATUS_DISCONNECTED, "Reconnect in "+str(int(backoff))+"s")
+
+
+
+ def disconnect(self):
+ self._stop_hello.set()
+ with self._lock:
+ self._manual_disconnect = True
+ self._reconnect_attempts = 0
+ if self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ link = self.link
+ self.link = None
+ if link is not None:
+ try: link.teardown()
+ except Exception: pass
+ self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected")
+
+ def set_auto_reconnect(self, enabled):
+ with self._lock:
+ self.auto_reconnect = bool(enabled)
+ if not enabled and self._reconnect_timer is not None:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def set_auto_list(self, enabled):
+ with self._lock:
+ self.auto_list = bool(enabled)
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def _packet_would_fit(self, link, payload):
+ try:
+ pkt = RNS.Packet(link, payload)
+ pkt.pack()
+ return True
+ except Exception:
+ return False
+
+ def _send_env(self, env):
+ with self._lock:
+ link = self.link
+ if link is None or link.status != RNS.Link.ACTIVE:
+ raise RuntimeError("not connected")
+ payload = cbor.encode(env)
+ if not self._packet_would_fit(link, payload):
+ raise RuntimeError("message exceeds link MTU")
+ RNS.Packet(link, payload).send()
+
+ def join_room(self, room, key=None):
+ r = self._normalize_room(room)
+ body = key if (isinstance(key, str) and key) else None
+ env = _make_envelope(T_JOIN, src=self.manager.identity.hash, room=r, body=body)
+ nick = self.manager.get_nickname()
+ if nick:
+ env[K_NICK] = nick
+ self._send_env(env)
+ with self._lock:
+ if r not in self.messages:
+ self.messages[r] = []
+ self.manager._notify_change(self)
+
+ def send_command(self, text, room=None):
+ if not isinstance(text, str) or not text.startswith("/"):
+ raise ValueError("command must start with /")
+ env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=room, body=text)
+ nick = self.manager.get_nickname()
+ if nick:
+ env[K_NICK] = nick
+ self._send_env(env)
+
+ def send_ping(self, room=None):
+ body = os.urandom(8)
+ env = _make_envelope(T_PING, src=self.manager.identity.hash, body=body)
+ with self._lock:
+ now_ms = _now_ms()
+ self._pending_pings[body] = (now_ms, room)
+ expired = [k for k, v in self._pending_pings.items() if now_ms - v[0] > 15000]
+ for k in expired:
+ self._pending_pings.pop(k, None)
+ self._send_env(env)
+ return body
+
+ def part_room(self, room):
+ room_n = self._normalize_room(room)
+ env = _make_envelope(T_PART, src=self.manager.identity.hash, room=room_n)
+ try:
+ self._send_env(env)
+ except Exception:
+ pass
+ with self._lock:
+ self.rooms.discard(room_n)
+ self.manager.save()
+ self.manager._notify_change(self)
+
+ def send_message(self, room, text):
+ r = self._normalize_room(room)
+ if not isinstance(text, str) or not text.strip():
+ raise ValueError("message text must be non-empty")
+ if len(text.encode("utf-8")) > self.max_msg_body_bytes:
+ raise ValueError("message too long for hub limit")
+ env = _make_envelope(T_MSG, src=self.manager.identity.hash, room=r, body=text)
+ nick = self.manager.get_nickname()
+ if nick:
+ env[K_NICK] = nick
+ mid = env[K_ID]
+ if isinstance(mid, (bytes, bytearray)):
+ self._sent_ids.append(bytes(mid))
+ self._send_env(env)
+ self._record_message(RRCMessage("msg", r, self.manager.identity.hash, nick, text, _now_ms()), local=True)
+ return mid
+
+ def _record_message(self, msg, local=False):
+ with self._lock:
+ buf = self.messages.setdefault(msg.room or "*", [])
+ buf.append(msg)
+ if len(buf) > 500:
+ del buf[:len(buf)-500]
+ if not local and msg.room:
+ if msg.room != self.manager.active_room_for(self):
+ self.unread_rooms.add(msg.room)
+ if msg.mention:
+ self.mention_rooms.add(msg.room)
+ self.manager._notify_messages(self, msg)
+
+ def _record_system(self, room, text):
+ if not room:
+ return
+ msg = RRCMessage("system", room, None, None, text, _now_ms())
+ with self._lock:
+ buf = self.messages.setdefault(room, [])
+ buf.append(msg)
+ if len(buf) > 500:
+ del buf[:len(buf)-500]
+ self.manager._notify_messages(self, msg)
+
+ def _record_notice(self, msg):
+ target_room = msg.room
+ if not target_room:
+ target_room = self.manager.active_room_for(self)
+ if target_room:
+ msg.room = target_room
+
+ with self._lock:
+ self.notices.append(msg)
+ if len(self.notices) > 200:
+ del self.notices[:len(self.notices)-200]
+ if target_room:
+ buf = self.messages.setdefault(target_room, [])
+ buf.append(msg)
+ if len(buf) > 500:
+ del buf[:len(buf)-500]
+ if target_room != self.manager.active_room_for(self):
+ self.unread_rooms.add(target_room)
+ self.manager._notify_messages(self, msg)
+
+ def get_messages(self, room):
+ with self._lock:
+ buf = list(self.messages.get(room, []))
+ return buf
+
+ def _on_packet(self, data):
+ try:
+ env = cbor.decode(data)
+ except Exception as e:
+ self._log("decode failed: "+str(e), RNS.LOG_DEBUG)
+ return
+ if not isinstance(env, dict):
+ return
+ try:
+ t = env.get(K_T)
+ except Exception:
+ return
+
+ if t == T_PING:
+ try:
+ pong = _make_envelope(T_PONG, src=self.manager.identity.hash, body=env.get(K_BODY))
+ self._send_env(pong)
+ except Exception:
+ pass
+ return
+
+ if t == T_PONG:
+ body = env.get(K_BODY)
+ if isinstance(body, (bytes, bytearray)):
+ key = bytes(body)
+ with self._lock:
+ pending = self._pending_pings.pop(key, None)
+ if pending is not None:
+ sent_ms, room = pending
+ rtt_ms = max(0, _now_ms() - sent_ms)
+ self._record_system(room, "Pong from hub: "+str(rtt_ms)+" ms")
+ return
+
+ if t == T_WELCOME:
+ self.welcomed = True
+ body = env.get(K_BODY)
+ if isinstance(body, dict):
+ hub_name = body.get(B_WELCOME_HUB)
+ if isinstance(hub_name, str):
+ self.hub_name = hub_name
+ ver = body.get(B_WELCOME_VER)
+ if isinstance(ver, str):
+ self.hub_version = ver
+ limits = body.get(B_WELCOME_LIMITS)
+ if isinstance(limits, dict):
+ if L_MAX_NICK_BYTES in limits:
+ self.max_nick_bytes = int(limits[L_MAX_NICK_BYTES])
+ if L_MAX_ROOM_NAME_BYTES in limits:
+ self.max_room_name_bytes = int(limits[L_MAX_ROOM_NAME_BYTES])
+ if L_MAX_MSG_BODY_BYTES in limits:
+ self.max_msg_body_bytes = int(limits[L_MAX_MSG_BODY_BYTES])
+ if L_MAX_ROOMS_PER_SESSION in limits:
+ self.max_rooms_per_session = int(limits[L_MAX_ROOMS_PER_SESSION])
+ if L_RATE_LIMIT_MSGS_PER_MINUTE in limits:
+ self.rate_limit_msgs_per_minute = int(limits[L_RATE_LIMIT_MSGS_PER_MINUTE])
+ self._set_status(RRCHub.STATUS_CONNECTED, "Connected")
+ with self._lock:
+ self._reconnect_attempts = 0
+ self.manager._on_welcome(self)
+ if self.auto_list:
+ try:
+ with self._lock:
+ self._silent_list_pending += 1
+ self.send_command("/list", room=None)
+ except Exception:
+ with self._lock:
+ if self._silent_list_pending > 0:
+ self._silent_list_pending -= 1
+ return
+
+ if t == T_JOINED:
+ room = env.get(K_ROOM)
+ if isinstance(room, str) and room:
+ r = room.strip().lower()
+ src = env.get(K_SRC)
+ nick = env.get(K_NICK)
+ body = env.get(K_BODY)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+ self_join = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash
+ with self._lock:
+ self.rooms.add(r)
+ if r not in self.messages:
+ self.messages[r] = []
+ members = self.members.setdefault(r, set())
+ if isinstance(body, list):
+ for entry in body:
+ if isinstance(entry, (bytes, bytearray)):
+ members.add(bytes(entry))
+ if isinstance(src, (bytes, bytearray)):
+ sb = bytes(src)
+ if own_hash is None or sb != own_hash:
+ members.add(sb)
+ if isinstance(nick, str) and nick:
+ self.nicks[sb] = nick
+ if own_hash is not None:
+ members.add(own_hash)
+ if self_join:
+ self._record_system(r, "You joined #"+r)
+ self.manager.save()
+ elif isinstance(src, (bytes, bytearray)):
+ self._record_system(r, self.display_name_for(src)+" joined")
+ self.manager._notify_change(self)
+ return
+
+ if t == T_PARTED:
+ room = env.get(K_ROOM)
+ if isinstance(room, str) and room:
+ r = room.strip().lower()
+ src = env.get(K_SRC)
+ body = env.get(K_BODY)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+ self_part = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash
+ with self._lock:
+ members = self.members.get(r)
+ if isinstance(body, list):
+ for entry in body:
+ if isinstance(entry, (bytes, bytearray)) and members is not None:
+ members.discard(bytes(entry))
+ elif isinstance(src, (bytes, bytearray)) and members is not None:
+ members.discard(bytes(src))
+ if self_part:
+ self.rooms.discard(r)
+ self.members.pop(r, None)
+ if self_part:
+ self.manager.save()
+ if not self_part and isinstance(src, (bytes, bytearray)):
+ self._record_system(r, self.display_name_for(src)+" left")
+ self.manager._notify_change(self)
+ return
+
+ if t == T_MSG:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ src = env.get(K_SRC)
+ nick = env.get(K_NICK)
+ mid = env.get(K_ID)
+ own_hash = self.manager.identity.hash if self.manager.identity is not None else None
+ if isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash:
+ if isinstance(mid, (bytes, bytearray)) and bytes(mid) in self._sent_ids:
+ return
+ if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick:
+ with self._lock:
+ self.nicks[bytes(src)] = nick
+ if isinstance(room, str) and room:
+ self.members.setdefault(room.strip().lower(), set()).add(bytes(src))
+ if isinstance(body, str):
+ msg = RRCMessage(
+ "msg",
+ room.strip().lower() if isinstance(room, str) else None,
+ bytes(src) if isinstance(src, (bytes, bytearray)) else None,
+ nick if isinstance(nick, str) else None,
+ body,
+ _now_ms(),
+ )
+ is_own = isinstance(src, (bytes, bytearray)) and own_hash is not None and bytes(src) == own_hash
+ if not is_own:
+ own_nick = self.manager.get_nickname()
+ pat = _mention_re(own_nick)
+ if pat is not None and pat.search(body):
+ msg.mention = True
+ self._record_message(msg)
+ return
+
+ if t == T_NOTICE:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ src = env.get(K_SRC)
+ if isinstance(body, str):
+ parsed = _parse_room_list_notice(body)
+ if parsed is not None:
+ with self._lock:
+ self.available_rooms = parsed
+ silent = self._silent_list_pending > 0
+ if silent:
+ self._silent_list_pending -= 1
+ self.manager._notify_change(self)
+ if silent:
+ return
+ msg = RRCMessage(
+ "notice",
+ room.strip().lower() if isinstance(room, str) else None,
+ bytes(src) if isinstance(src, (bytes, bytearray)) else None,
+ None,
+ body,
+ _now_ms(),
+ )
+ self._record_notice(msg)
+ return
+
+ if t == T_ERROR:
+ body = env.get(K_BODY)
+ room = env.get(K_ROOM)
+ text = body if isinstance(body, str) else "(error)"
+ msg = RRCMessage(
+ "error",
+ room.strip().lower() if isinstance(room, str) else None,
+ None,
+ None,
+ text,
+ _now_ms(),
+ )
+ self._record_notice(msg)
+ return
+
+ if t == T_RESOURCE_ENVELOPE:
+ body = env.get(K_BODY)
+ if not isinstance(body, dict):
+ return
+ try:
+ rid = body.get(B_RES_ID)
+ kind = body.get(B_RES_KIND)
+ size = body.get(B_RES_SIZE)
+ sha256 = body.get(B_RES_SHA256)
+ encoding = body.get(B_RES_ENCODING)
+ if not isinstance(rid, (bytes, bytearray)): return
+ if not isinstance(kind, str): return
+ if not isinstance(size, int) or size <= 0: return
+ room = env.get(K_ROOM)
+ with self._lock:
+ self._resource_expectations[bytes(rid)] = {
+ "kind": kind,
+ "size": size,
+ "sha256": bytes(sha256) if isinstance(sha256, (bytes, bytearray)) else None,
+ "encoding": encoding if isinstance(encoding, str) else "utf-8",
+ "room": room.strip().lower() if isinstance(room, str) else None,
+ "expires": time.monotonic()+30.0,
+ }
+ except Exception:
+ pass
+ return
+
+ def _resource_advertised(self, resource):
+ try:
+ if hasattr(resource, "get_data_size"):
+ size = resource.get_data_size()
+ elif hasattr(resource, "total_size"):
+ size = resource.total_size
+ else:
+ size = getattr(resource, "size", 0)
+ except Exception:
+ return False
+ if size > 262144:
+ return False
+ return True
+
+ def _resource_concluded(self, resource):
+ try:
+ if resource.status != RNS.Resource.COMPLETE:
+ try:
+ if hasattr(resource, "data") and resource.data:
+ resource.data.close()
+ except Exception:
+ pass
+ return
+ try:
+ size = resource.total_size if hasattr(resource, "total_size") else getattr(resource, "size", 0)
+ except Exception:
+ size = 0
+ data = None
+ try:
+ data = resource.data.read()
+ finally:
+ try:
+ if hasattr(resource, "data") and resource.data:
+ resource.data.close()
+ except Exception:
+ pass
+ if data is None:
+ return
+
+ now = time.monotonic()
+ matched = None
+ with self._lock:
+ expired = [k for k, v in self._resource_expectations.items() if v["expires"] < now]
+ for k in expired:
+ self._resource_expectations.pop(k, None)
+ for k, exp in list(self._resource_expectations.items()):
+ if exp["size"] == len(data):
+ matched = exp
+ self._resource_expectations.pop(k, None)
+ break
+
+ kind = matched["kind"] if matched else RES_KIND_BLOB
+ room = matched["room"] if matched else None
+ encoding = matched["encoding"] if matched else "utf-8"
+ sha = matched["sha256"] if matched else None
+ if sha is not None:
+ if hashlib.sha256(data).digest() != sha:
+ return
+ if kind in (RES_KIND_NOTICE, RES_KIND_MOTD):
+ try:
+ text = data.decode(encoding, errors="replace")
+ except Exception:
+ return
+ msg = RRCMessage("notice", room, None, None, text, _now_ms())
+ self._record_notice(msg)
+ except Exception as e:
+ self._log("resource handling failed: "+str(e), RNS.LOG_ERROR)
+
+
+class RRCManager:
+ def __init__(self, app):
+ self.app = app
+ self.hubs = []
+ self._lock = threading.RLock()
+ self._change_callback = None
+ self._message_callback = None
+ self._active_hub = None
+ self._active_room = None
+ self._loaded = False
+ self._loading = False
+ self._save_lock = threading.Lock()
+
+ @property
+ def identity(self):
+ return self.app.identity
+
+ def get_nickname(self):
+ try:
+ n = self.app.peer_settings.get("display_name")
+ if isinstance(n, str):
+ return n
+ except Exception:
+ pass
+ return None
+
+ def set_change_callback(self, cb):
+ self._change_callback = cb
+
+ def set_message_callback(self, cb):
+ self._message_callback = cb
+
+ def _notify_change(self, hub=None):
+ try:
+ if self._change_callback is not None:
+ self._change_callback(hub)
+ except Exception:
+ pass
+
+ def _notify_messages(self, hub, msg):
+ try:
+ if self._message_callback is not None:
+ self._message_callback(hub, msg)
+ except Exception:
+ pass
+
+ def _on_welcome(self, hub):
+ for r in list(hub.rooms):
+ try:
+ hub.join_room(r)
+ except Exception:
+ pass
+
+ def set_active(self, hub, room):
+ self._active_hub = hub
+ self._active_room = room
+ if hub is not None and room is not None:
+ hub.mark_read(room)
+
+ def active_room_for(self, hub):
+ if self._active_hub is hub:
+ return self._active_room
+ return None
+
+ def has_unread(self):
+ with self._lock:
+ for hub in self.hubs:
+ if hub.unread_rooms:
+ return True
+ return False
+
+ def add_hub(self, hub_hash, dest_name=None, name=None):
+ with self._lock:
+ for h in self.hubs:
+ if h.hub_hash == hub_hash and (h.dest_name == (dest_name or DEFAULT_DEST_NAME)):
+ return h
+ hub = RRCHub(self, hub_hash, dest_name=dest_name, name=name)
+ self.hubs.append(hub)
+ self.save()
+ self._notify_change()
+ return hub
+
+ def remove_hub(self, hub):
+ with self._lock:
+ if hub in self.hubs:
+ self.hubs.remove(hub)
+ try:
+ hub.disconnect()
+ except Exception:
+ pass
+ self.save()
+ self._notify_change()
+
+ def find_hub(self, hub_hash, dest_name=None):
+ dn = dest_name or DEFAULT_DEST_NAME
+ with self._lock:
+ for h in self.hubs:
+ if h.hub_hash == hub_hash and h.dest_name == dn:
+ return h
+ return None
+
+ def _store_path(self):
+ return os.path.join(self.app.storagepath, "rrc_hubs")
+
+ def load(self):
+ if self._loaded:
+ return
+ self._loaded = True
+ path = self._store_path()
+ if not os.path.isfile(path):
+ return
+ self._loading = True
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ obj = cbor.decode(data)
+ if not isinstance(obj, dict):
+ return
+ entries = obj.get("hubs")
+ if not isinstance(entries, list):
+ return
+ for e in entries:
+ if not isinstance(e, dict):
+ continue
+ hh = e.get("hash")
+ if not isinstance(hh, (bytes, bytearray)):
+ continue
+ dn = e.get("dest_name")
+ nm = e.get("name")
+ hub = self.add_hub(bytes(hh), dest_name=dn if isinstance(dn, str) else None, name=nm if isinstance(nm, str) else None)
+ rooms = e.get("rooms")
+ if isinstance(rooms, list):
+ for r in rooms:
+ if isinstance(r, str):
+ hub.add_room(r)
+ parted = e.get("parted_rooms")
+ if isinstance(parted, list):
+ for r in parted:
+ if isinstance(r, str):
+ try:
+ rn = hub._normalize_room(r)
+ with hub._lock:
+ hub.messages.setdefault(rn, [])
+ except Exception:
+ pass
+ ar = e.get("auto_reconnect")
+ if isinstance(ar, bool):
+ hub.auto_reconnect = ar
+ al = e.get("auto_list")
+ if isinstance(al, bool):
+ hub.auto_list = al
+ except Exception as e:
+ RNS.log("Failed to load RRC hubs: "+str(e), RNS.LOG_ERROR)
+ finally:
+ self._loading = False
+
+ def save(self):
+ if self._loading:
+ return
+ path = self._store_path()
+ tmp_path = path + ".tmp"
+ with self._save_lock:
+ try:
+ entries = []
+ with self._lock:
+ for h in self.hubs:
+ joined = set(h.rooms)
+ parted = set(h.messages.keys()) - joined
+ entries.append({
+ "hash": h.hub_hash,
+ "dest_name": h.dest_name,
+ "name": h.name,
+ "rooms": sorted(joined),
+ "parted_rooms": sorted(parted),
+ "auto_reconnect": bool(h.auto_reconnect),
+ "auto_list": bool(h.auto_list),
+ })
+ data = cbor.encode({"hubs": entries})
+ with open(tmp_path, "wb") as f:
+ f.write(data)
+ f.flush()
+ try: os.fsync(f.fileno())
+ except Exception: pass
+ os.replace(tmp_path, path)
+ except Exception as e:
+ #
+ #
+ #
+ #
+ try: os.unlink(tmp_path)
+ except Exception: pass
+
+ def shutdown(self):
+ for h in list(self.hubs):
+ try:
+ h.disconnect()
+ except Exception:
+ pass
diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py
index 48bd74f..f05ae81 100644
--- a/nomadnet/ui/TextUI.py
+++ b/nomadnet/ui/TextUI.py
@@ -59,6 +59,13 @@ THEMES = {
("placeholder", "dark gray", "default", "default", "dark gray", "default"),
("placeholder_text", "dark gray", "default", "default", "dark gray", "default"),
("error", "light red,blink", "default", "blink", "#f44,blink", "default"),
+ ("irc_ts", "dark gray", "default", "default", "#888", "default"),
+ ("irc_nick_self", "light green", "default", "default", "#6c5", "default"),
+ ("irc_nick_peer", "light cyan", "default", "default", "#3cd", "default"),
+ ("irc_notice", "yellow", "default", "default", "#fd3", "default"),
+ ("irc_error", "light red", "default", "default", "#f55", "default"),
+ ("irc_system", "dark gray", "default", "default", "#888", "default"),
+ ("irc_mention", "light red,bold", "default", "bold", "#fb4,bold", "default"),
],
},
@@ -103,7 +110,14 @@ THEMES = {
("placeholder", "light gray", "default", "default", "#999", "default"),
("placeholder_text", "light gray", "default", "default", "#999", "default"),
("error", "dark red,blink", "default", "blink", "#a22,blink", "default"),
- ],
+ ("irc_ts", "dark gray", "default", "default", "#888", "default"),
+ ("irc_nick_self", "dark green", "default", "default", "#3a0", "default"),
+ ("irc_nick_peer", "dark cyan", "default", "default", "#077", "default"),
+ ("irc_notice", "brown", "default", "default", "#a70", "default"),
+ ("irc_error", "dark red", "default", "default", "#a22", "default"),
+ ("irc_system", "dark gray", "default", "default", "#888", "default"),
+ ("irc_mention", "dark red,bold", "default", "bold", "#c50,bold", "default"),
+ ],
}
}
diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py
index ada2ffe..1994ac9 100644
--- a/nomadnet/ui/textui/Browser.py
+++ b/nomadnet/ui/textui/Browser.py
@@ -192,6 +192,8 @@ class Browser:
return "nomadnetwork.node"
elif destination_type == "lxmf":
return "lxmf.delivery"
+ elif destination_type == "rrc":
+ return "rrc.hub.session"
else:
return destination_type
@@ -250,6 +252,11 @@ class Browser:
recurse_down(self.attr_maps)
RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
+ # rrc://<hex>[:<dest_name>]/<room> URL form
+ if link_target.startswith("rrc://"):
+ self.handle_rrc_link(link_target[6:])
+ return
+
components = link_target.split("@")
destination_type = None
@@ -280,6 +287,10 @@ class Browser:
RNS.log("Passing LXMF link to handler", RNS.LOG_DEBUG)
self.handle_lxmf_link(link_target)
+ elif destination_type == "rrc.hub.session":
+ RNS.log("Passing RRC link to handler", RNS.LOG_DEBUG)
+ self.handle_rrc_link(link_target)
+
elif destination_type == "partial":
if partial_ids != None and len(partial_ids) > 0: self.handle_partial_updates(partial_ids)
@@ -331,6 +342,56 @@ class Browser:
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+ def handle_rrc_link(self, link_target):
+ try:
+ if not isinstance(link_target, str):
+ raise ValueError("invalid RRC link payload")
+ rest = link_target.strip()
+ if rest.startswith("/"):
+ rest = rest[1:]
+ hub_part, _, room = rest.partition("/")
+ hex_part, _, dest = hub_part.partition(":")
+ hex_part = hex_part.strip()
+ dest = dest.strip() or None
+ try:
+ hub_hash = bytes.fromhex(hex_part)
+ except Exception:
+ raise ValueError("invalid hub hash")
+ expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
+ if len(hub_hash) != expected_len:
+ raise ValueError("hub hash must be "+str(expected_len)+" bytes")
+
+ room = room.strip().lstrip("#").strip()
+ room_norm = None
+ if room:
+ try:
+ # validate the room name early; pass the raw value through
+ from nomadnet.RRC import RRCHub as _RRCHubCls # noqa
+ room_norm = room.lower()
+ except Exception:
+ room_norm = None
+
+ existing = self.app.rrc.find_hub(hub_hash, dest_name=dest)
+ self.app.ui.main_display.show_channels(None)
+ channels = self.app.ui.main_display.sub_displays.channels_display
+
+ if existing is not None:
+ channels.update_list()
+ if room_norm:
+ channels._select_room(None, (existing, room_norm))
+ else:
+ channels._select_hub(None, existing)
+ return
+
+
+ channels.confirm_new_hub_dialog(hub_hash, dest, room_norm)
+
+ except Exception as e:
+ RNS.log("Could not open RRC link: "+str(e), RNS.LOG_ERROR)
+ self.browser_footer = urwid.Text("Could not open RRC link: "+str(e))
+ self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
+
+
def micron_released_focus(self):
if self.delegate != None:
self.delegate.focus_lists()
diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py
new file mode 100644
index 0000000..5a9c7f0
--- /dev/null
+++ b/nomadnet/ui/textui/Channels.py
@@ -0,0 +1,1488 @@
+import collections
+import os
+import re
+import time
+
+import RNS
+import urwid
+
+import nomadnet
+from nomadnet.RRC import RRCHub
+from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
+from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec
+
+
+class _ChatLinkableText(LinkableText):
+ def render(self, size, focus=False):
+ c = urwid.Text.render(self, size, focus)
+ if focus:
+ c = urwid.CompositeCanvas(c)
+ c.cursor = self.get_cursor_coords(size)
+ if self.delegate is not None:
+ self.peek_link()
+ return c
+
+
+_LINK_RE = re.compile(
+ r"(?P<lxmf>(?<!\w)lxmf@[0-9a-fA-F]{32})(?!\w)"
+ r"|(?P<page>(?<![@\w])[0-9a-fA-F]{32}(?::\S+)?)(?!\w)"
+ r"|(?P<room>(?<!\w)#[A-Za-z0-9][A-Za-z0-9_\-]{0,62})"
+)
+
+
+
+
+
+
+
+
+def _link_attrs():
+ return {
+ "room": urwid.AttrSpec("light cyan,underline", "default", colors=256),
+ "lxmf": urwid.AttrSpec("light magenta,underline", "default", colors=256),
+ "page": urwid.AttrSpec("light blue,underline", "default", colors=256),
+ }
+
+
+_LINK_ATTRS = _link_attrs()
+
+
+def _scan_links(text):
+ for m in _LINK_RE.finditer(text):
+ if m.group("lxmf"):
+ yield m.start(), m.end(), "lxmf", m.group()[5:]
+ elif m.group("page"):
+ yield m.start(), m.end(), "page", m.group()
+ elif m.group("room"):
+ yield m.start(), m.end(), "room", m.group()[1:]
+
+
+def _chunk_by_bytes(s, budget):
+ chunks = []
+ remaining = s
+ while remaining:
+ encoded = remaining.encode("utf-8")
+ if len(encoded) <= budget:
+ chunks.append(remaining)
+ break
+ cut = encoded[:budget]
+ while cut and (cut[-1] & 0xC0) == 0x80:
+ cut = cut[:-1]
+ chunk = cut.decode("utf-8", errors="ignore")
+ last_space = max(chunk.rfind(" "), chunk.rfind("\n"), chunk.rfind("\t"))
+ if last_space > 0 and last_space >= len(chunk) // 2:
+ chunk = chunk[:last_space]
+ if not chunk:
+ chunk = remaining[:1]
+ chunks.append(chunk.rstrip())
+ remaining = remaining[len(chunk):].lstrip()
+ return chunks
+
+
+def _split_message(text, max_bytes):
+ if not text:
+ return [text]
+ parts = [text]
+ for _attempt in range(10):
+ K_guess = max(1, len(parts))
+ prefix_bytes = len(("({}/{}) ".format(K_guess, K_guess)).encode("utf-8"))
+ budget = max_bytes - prefix_bytes
+ if budget <= 0:
+ return None
+ parts = _chunk_by_bytes(text, budget)
+ if len(parts) == K_guess:
+ break
+ K = len(parts)
+ return ["({}/{}) ".format(i+1, K) + p for i, p in enumerate(parts)]
+
+
+def _scan_mentions(text, own_nick):
+ if not own_nick or not text:
+ return
+ pat = re.compile(r"(?<![A-Za-z0-9_])@"+re.escape(own_nick)+r"(?![A-Za-z0-9_])", re.IGNORECASE) # @(....)
+ for m in pat.finditer(text):
+ yield m.start(), m.end(), "mention", None
+
+
+def _body_markup(body, body_attr="body_text", own_nick=None):
+ spans = list(_scan_links(body))
+ spans.extend(_scan_mentions(body, own_nick))
+ spans.sort(key=lambda s: s[0])
+ filtered = []
+ last_end = 0
+ for s in spans:
+ if s[0] >= last_end:
+ filtered.append(s)
+ last_end = s[1]
+ spans = filtered
+
+ if not spans:
+ return [(body_attr, body)], False
+
+ out = []
+ pos = 0
+ has_links = False
+ for start, end, kind, target in spans:
+ if start > pos:
+ out.append((body_attr, body[pos:start]))
+ if kind == "mention":
+ out.append(("irc_mention", body[start:end]))
+ else:
+ base = _LINK_ATTRS[kind]
+ out.append((LinkSpec(kind+":"+target, base, cm=256), body[start:end]))
+ has_links = True
+ pos = end
+ if pos < len(body):
+ out.append((body_attr, body[pos:]))
+ return out, has_links
+
+
+def _short_hash(b, n=10):
+ if isinstance(b, (bytes, bytearray)):
+ return bytes(b).hex()[:n]
+ return "?"
+
+
+def _format_ts(ts_ms):
+ try:
+ return time.strftime("%H:%M:%S", time.localtime(ts_ms/1000.0))
+ except Exception:
+ return ""
+
+
+class ChannelsListShortcuts():
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar")
+
+
+class ChannelsRoomShortcuts():
+ def __init__(self, app):
+ self.app = app
+ self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave Room [C-k] Clear Editor [C-u] Toggle Users [Tab] Switch Focus"), "shortcutbar")
+
+
+class ChannelsDialogLineBox(urwid.LineBox):
+ def keypress(self, size, key):
+ if key == "esc":
+ if hasattr(self.delegate, "close_dialog"):
+ self.delegate.close_dialog()
+ else:
+ return super(ChannelsDialogLineBox, self).keypress(size, key)
+
+
+class ChannelListEntry(urwid.Text):
+ _selectable = True
+ signals = ["click"]
+
+ def keypress(self, size, key):
+ if self._command_map[key] != urwid.ACTIVATE:
+ return key
+ self._emit("click")
+
+ def mouse_event(self, size, event, button, x, y, focus):
+ if button != 1 or not urwid.util.is_mouse_press(event):
+ return False
+ self._emit("click")
+ return True
+
+
+class ChannelsListArea(urwid.LineBox):
+ def keypress(self, size, key):
+ if key == "ctrl n":
+ self.delegate.new_hub_dialog()
+ elif key == "ctrl a":
+ self.delegate.join_room_dialog()
+ elif key == "ctrl r":
+ self.delegate.connect_selected()
+ elif key == "ctrl w":
+ self.delegate.disconnect_selected()
+ elif key == "ctrl t":
+ self.delegate.toggle_auto_reconnect_selected()
+ elif key == "ctrl e":
+ self.delegate.edit_hub_dialog()
+ elif key == "ctrl x":
+ self.delegate.remove_selected_dialog()
+ elif key == "tab":
+ self.delegate.app.ui.main_display.frame.focus_position = "header"
+ elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()):
+ self.delegate.app.ui.main_display.frame.focus_position = "header"
+ else:
+ return super(ChannelsListArea, self).keypress(size, key)
+
+
+class RoomMessageEdit(urwid.Edit):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._tab_state = None
+
+ def keypress(self, size, key):
+ if key == "tab":
+ if self._try_tab_complete():
+ return None
+ return key
+ self._tab_state = None
+ if key == "ctrl d":
+ self.delegate.send_message()
+ elif key == "ctrl k":
+ self.set_edit_text("")
+ elif key == "ctrl l":
+ self.delegate.leave_room()
+ elif key == "ctrl u":
+ self.delegate.toggle_users()
+ elif key == "up":
+ y = self.get_cursor_coords(size)[1]
+ if y == 0:
+ self.delegate.frame.focus_position = "body"
+ else:
+ return super(RoomMessageEdit, self).keypress(size, key)
+ else:
+ return super(RoomMessageEdit, self).keypress(size, key)
+
+ def _candidates(self, prefix_lower):
+ delegate = getattr(self, "delegate", None)
+ if delegate is None or delegate.hub is None or delegate.room is None:
+ return []
+ members = delegate.hub.get_members(delegate.room)
+ own_hash = None
+ try:
+ if delegate.app.identity is not None:
+ own_hash = delegate.app.identity.hash
+ except Exception:
+ pass
+ names = set()
+ for m in members:
+ if own_hash is not None and m == own_hash:
+ continue
+ names.add(delegate.hub.display_name_for(m))
+ return sorted([n for n in names if n.lower().startswith(prefix_lower)],
+ key=str.lower)
+
+ def _try_tab_complete(self):
+ text = self.get_edit_text()
+ pos = self.edit_pos
+ state = self._tab_state
+
+ if state is not None and state.get("cursor_after") == pos:
+ prefix_lower = state["prefix"]
+ token_start = state["token_start"]
+ has_at = state["has_at"]
+ matches = self._candidates(prefix_lower)
+ if not matches:
+ self._tab_state = None
+ return False
+ idx = (state["idx"] + 1) % len(matches)
+ else:
+ start = pos
+ while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-"):
+ start -= 1
+ has_at = start > 0 and text[start-1] == "@"
+ token_start = start - 1 if has_at else start
+ token = text[start:pos]
+ if not token:
+ return False
+ prefix_lower = token.lower()
+ matches = self._candidates(prefix_lower)
+ if not matches:
+ return False
+ idx = 0
+
+ selected = matches[idx]
+ if has_at:
+ replacement = "@" + selected
+ elif token_start == 0:
+ replacement = selected + ": "
+ else:
+ replacement = selected
+
+ new_text = text[:token_start] + replacement + text[pos:]
+ new_cursor = token_start + len(replacement)
+ self.set_edit_text(new_text)
+ self.set_edit_pos(new_cursor)
+ self._tab_state = {
+ "prefix": prefix_lower,
+ "token_start": token_start,
+ "has_at": has_at,
+ "cursor_after": new_cursor,
+ "idx": idx,
+ }
+ return True
+
+
+class RoomFrame(urwid.Frame):
+ def keypress(self, size, key):
+ if key == "ctrl u":
+ self.delegate.toggle_users()
+ return None
+ if key == "tab":
+ if self.focus_position == "body":
+ self.focus_position = "footer"
+ else:
+ self.focus_position = "body"
+ elif self.focus_position == "body":
+ if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible:
+ self.focus_position = "footer"
+ elif key == "up" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.top_is_visible:
+ nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header"
+ else:
+ return super(RoomFrame, self).keypress(size, key)
+ else:
+ return super(RoomFrame, self).keypress(size, key)
+
+
+class RoomWidget(urwid.WidgetWrap):
+ USERS_PANE_WIDTH = 22
+
+ def __init__(self, display, hub, room):
+ self.display = display
+ self.hub = hub
+ self.room = room
+ self.app = nomadnet.NomadNetworkApp.get_shared_instance()
+
+ self.messagelist = None
+ self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent")
+ self._update_peer_info()
+
+ editor = RoomMessageEdit(caption="", edit_text="", multiline=True)
+ editor.delegate = self
+ self.editor = editor
+ editor_attr = urwid.AttrMap(editor, "msg_editor")
+
+ self.link_delegate = _ChatLinkDelegate(self.display, self.hub)
+ self.update_messages()
+
+ self.frame = RoomFrame(
+ self.messagelist,
+ header=self.peer_info_widget,
+ footer=editor_attr,
+ focus_part="footer",
+ )
+ self.frame.delegate = self
+
+ self.chat_box = urwid.LineBox(self.frame)
+ self.users_pile = urwid.Pile([urwid.Text("")])
+ self.users_box = urwid.LineBox(urwid.Filler(self.users_pile, "top"), title="Users")
+ self._refresh_users_pane()
+
+ self.users_visible = self.display.users_visible
+ self.columns = urwid.Columns([(urwid.WEIGHT, 1, self.chat_box)], dividechars=0, focus_column=0)
+ self._apply_users_visibility()
+ super().__init__(self.columns)
+
+ def toggle_users(self):
+ self.users_visible = not self.users_visible
+ self.display.users_visible = self.users_visible
+ self._apply_users_visibility()
+
+ def _apply_users_visibility(self):
+ if self.users_visible:
+ self.columns.contents = [
+ (self.chat_box, self.columns.options(urwid.WEIGHT, 1)),
+ (self.users_box, self.columns.options(urwid.GIVEN, RoomWidget.USERS_PANE_WIDTH)),
+ ]
+ else:
+ self.columns.contents = [
+ (self.chat_box, self.columns.options(urwid.WEIGHT, 1)),
+ ]
+ self.columns.focus_position = 0
+
+ def _refresh_users_pane(self):
+ g = self.app.ui.glyphs
+ if self.hub is None or self.room is None:
+ self.users_pile.contents = [(urwid.Text(""), self.users_pile.options())]
+ return
+ members = self.hub.get_members(self.room)
+ own_hash = self.app.identity.hash if self.app.identity is not None else None
+ names = []
+ for m in members:
+ if own_hash is not None and m == own_hash:
+ names.append((self.hub.display_name_for(m), True))
+ else:
+ names.append((self.hub.display_name_for(m), False))
+ names.sort(key=lambda x: x[0].lower())
+
+ rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))]
+ for name, is_self in names:
+ if is_self:
+ rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted"))
+ else:
+ rows.append(urwid.AttrMap(urwid.Text(" "+g["peer"]+" "+name), "connected_status"))
+ if not names:
+ rows.append(urwid.Text(" (no members)"))
+ self.users_pile.contents = [(w, self.users_pile.options()) for w in rows]
+
+ def _update_peer_info(self):
+ if self.hub is None or self.room is None:
+ self.peer_info_widget.original_widget.set_text("")
+ return
+
+ status_label = {
+ RRCHub.STATUS_DISCONNECTED: "Disconnected",
+ RRCHub.STATUS_CONNECTING: "Connecting",
+ RRCHub.STATUS_CONNECTED: "Connected",
+ RRCHub.STATUS_FAILED: "Failed",
+ }.get(self.hub.status, "")
+
+ server = ""
+ if self.hub.hub_name:
+ server = " "+self.app.ui.glyphs["divider1"]+" "+self.hub.hub_name
+ if self.hub.hub_version:
+ server += " v"+self.hub.hub_version
+ left = " #"+self.room+server+" ("+self.hub.name+")"
+ right = status_label+" "
+ self.peer_info_widget.original_widget.set_text(left+" | "+right)
+
+ MAX_RENDERED_MESSAGES = 500
+
+ def update_messages(self, replace=False):
+ msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else []
+ widgets = []
+ for m in msgs:
+ widgets.append(_message_widget(self.app, self.hub, m, link_delegate=self.link_delegate))
+
+ if not widgets:
+ widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])]
+ self._empty_placeholder = True
+ else:
+ self._empty_placeholder = False
+
+ self.messagelist = IndicativeListBox(widgets, position=len(widgets)-1)
+ self.messagelist.name = "messagelist"
+ if replace and hasattr(self, "frame"):
+ self.frame.contents["body"] = (self.messagelist, None)
+ if hasattr(self, "users_pile"):
+ self._refresh_users_pane()
+
+ def append_message(self, msg):
+ if self.messagelist is None:
+ self.update_messages(replace=True)
+ return
+ try:
+ widget = _message_widget(self.app, self.hub, msg, link_delegate=self.link_delegate)
+ wrapped = urwid.AttrMap(widget, None)
+ body = self.messagelist._listbox.body
+ if getattr(self, "_empty_placeholder", False):
+ del body[:]
+ self._empty_placeholder = False
+ body.append(wrapped)
+ while len(body) > self.MAX_RENDERED_MESSAGES:
+ del body[0]
+ try:
+ self.messagelist._listbox.set_focus(len(body)-1)
+ except Exception:
+ pass
+ except Exception as e:
+ RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG)
+ self.update_messages(replace=True)
+ if hasattr(self, "users_pile"):
+ self._refresh_users_pane()
+
+ def send_message(self):
+ text = self.editor.get_edit_text()
+ if not text.strip():
+ return
+ if text.lstrip().startswith("/"):
+ self._handle_slash_command(text.lstrip())
+ self.editor.set_edit_text("")
+ return
+ if self.hub.status != RRCHub.STATUS_CONNECTED:
+ try:
+ self.hub.connect()
+ except Exception:
+ pass
+ return
+ limit = self.hub.max_msg_body_bytes or 350
+ if len(text.encode("utf-8")) > limit:
+ self._open_split_dialog(text, limit)
+ return
+ try:
+ self.hub.send_message(self.room, text)
+ self.editor.set_edit_text("")
+ except Exception as e:
+ RNS.log("Failed to send RRC message: "+str(e), RNS.LOG_ERROR)
+
+ def _open_split_dialog(self, text, limit):
+ body_bytes = len(text.encode("utf-8"))
+ parts = _split_message(text, limit)
+ if not parts:
+ self._local_message("error",
+ "Message is "+str(body_bytes)+" bytes but per-message limit is too small to split.")
+ return
+ K = len(parts)
+ preview = parts[0]
+ if len(preview) > 70:
+ preview = preview[:70] + "…"
+ preview = preview.replace("\n", " ").replace("\t", " ")
+
+ error_text = urwid.Text("")
+
+ def cancel(sender):
+ self.display.close_dialog()
+
+ def send_split(sender):
+ try:
+ for p in parts:
+ self.hub.send_message(self.room, p)
+ self.editor.set_edit_text("")
+ self.display.close_dialog()
+ except Exception as e:
+ error_text.set_text(("error_text", "Send failed: "+str(e)))
+
+ dialog = ChannelsDialogLineBox(
+ urwid.Pile([
+ urwid.Text(""),
+ urwid.Text(" Message is "+str(body_bytes)+" bytes."),
+ urwid.Text(" Hub limit : "+str(limit)+" bytes per message."),
+ urwid.Text(""),
+ urwid.Text(" Split into "+str(K)+" message"+("s" if K != 1 else "")+"."),
+ urwid.Text(" Preview of part 1:"),
+ urwid.AttrMap(urwid.Text(" "+preview), "irc_system"),
+ urwid.Text(""),
+ error_text,
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Send Split", on_press=send_split)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel)),
+ ])
+ ]), title="Message Too Long"
+ )
+ dialog.delegate = self.display
+ self.display._show_dialog_overlay(dialog)
+
+ def _local_message(self, kind, text):
+ from nomadnet.RRC import RRCMessage
+ msg = RRCMessage(kind, self.room, None, None, text, int(time.time()*1000))
+ with self.hub._lock:
+ buf = self.hub.messages.setdefault(self.room, [])
+ buf.append(msg)
+ if len(buf) > 500:
+ del buf[:len(buf)-500]
+ self.hub.manager._notify_messages(self.hub, msg)
+ # printed /help
+ SLASH_HELP = [
+ "/help - show this list",
+ "/ping - measure round-trip to hub",
+ "/list - list public rooms on this hub",
+ "/join <room> - join a room on this hub",
+ "/part [room] - leave a room (default: current)",
+ "/leave [room] - alias for /part",
+ "/nick <name> - set your display name",
+ "/who [room] - list users (current room if omitted)",
+ "/names [room] - alias for /who",
+ "/clear - clear local messages in this room",
+ "/connect - connect this hub",
+ "/disconnect - disconnect this hub",
+ "/quit - alias for /disconnect",
+ "",
+ "Server-side commands (auth enforced by hub):",
+ "/topic <room> [text] - view or set room topic",
+ "/mode <room> [+-flags] [arg] - view or set room modes",
+ "/register <room> - register the current room",
+ "/unregister <room> - unregister the current room",
+ "/kick <room> <target> - remove user from room",
+ "/ban <room> add|del|list [target] - room ban list",
+ "/invite <room> add|del|list [target] - room invite list",
+ "/op <room> <target> - grant op",
+ "/deop <room> <target> - revoke op",
+ "/voice <room> <target> - grant voice",
+ "/devoice <room> <target> - revoke voice",
+ "/kline add|del|list [target] - global ban",
+ "/stats - server statistics",
+ "/reload - reload server config",
+ ]
+
+ # commands that we forward to the server verbatim
+ SERVER_SLASH_COMMANDS = {
+ "who", "names",
+ "topic", "mode", "kick", "kline",
+ "ban", "invite", "kline",
+ "op", "deop", "voice", "devoice",
+ "register", "unregister",
+ "stats", "reload",
+ }
+
+ def _require_connected(self):
+ if self.hub.status != RRCHub.STATUS_CONNECTED:
+ self._local_message("error", "Not connected to hub")
+ return False
+ return True
+
+ def _handle_slash_command(self, text):
+ parts = text[1:].split(None, 1)
+ if not parts or not parts[0]:
+ self._local_message("error", "Empty command")
+ return
+ cmd = parts[0].lower()
+ arg = parts[1].strip() if len(parts) > 1 else ""
+
+ if cmd == "help":
+ for line in self.SLASH_HELP:
+ self._local_message("system", line)
+ return
+
+ if cmd == "ping":
+ if not self._require_connected():
+ return
+ try:
+ self.hub.send_ping(room=self.room)
+ self._local_message("system", "Ping sent")
+ except Exception as e:
+ self._local_message("error", "Ping failed: "+str(e))
+ return
+
+ if cmd == "list":
+ if not self._require_connected():
+ return
+ try:
+ self.hub.send_command("/list", room=self.room)
+ except Exception as e:
+ self._local_message("error", "/list failed: "+str(e))
+ return
+
+ if cmd in ("join", "j"):
+ if not arg:
+ self._local_message("error", "Usage: /join <room>")
+ return
+ target = arg.lstrip("#").strip()
+ try:
+ self.hub.add_room(target)
+ if self.hub.status == RRCHub.STATUS_CONNECTED:
+ self.hub.join_room(target)
+ self.display.update_list()
+ self.display._select_room(None, (self.hub, target.lower()))
+ except Exception as e:
+ self._local_message("error", "Join failed: "+str(e))
+ return
+
+ if cmd in ("part", "leave"):
+ target = (arg.lstrip("#").strip().lower()) if arg else self.room
+ try:
+ self.hub.part_room(target)
+ self.display.update_list()
+ if target == self.room:
+ self.display.show_placeholder()
+ except Exception as e:
+ self._local_message("error", "Part failed: "+str(e))
+ return
+
+ if cmd == "nick":
+ if not arg:
+ cur = self.app.rrc.get_nickname() or "(unset)"
+ self._local_message("system", "Current nick: "+cur)
+ return
+ limit = self.hub.max_nick_bytes or 32
+ if len(arg.encode("utf-8")) > limit:
+ self._local_message("error", "Nick too long (max "+str(limit)+" bytes)")
+ return
+ try:
+ self.app.set_display_name(arg)
+ self._local_message("system", "Nick set to "+arg)
+ except Exception as e:
+ self._local_message("error", "Nick change failed: "+str(e))
+ return
+
+ if cmd == "clear":
+ with self.hub._lock:
+ self.hub.messages[self.room] = []
+ self.update_messages(replace=True)
+ return
+
+ if cmd == "connect":
+ try:
+ self.hub.connect()
+ self._local_message("system", "Connecting...")
+ except Exception as e:
+ self._local_message("error", "Connect failed: "+str(e))
+ return
+
+ if cmd in ("disconnect", "quit"):
+ try:
+ self.hub.disconnect()
+ except Exception as e:
+ self._local_message("error", "Disconnect failed: "+str(e))
+ return
+
+ if cmd in self.SERVER_SLASH_COMMANDS:
+ if not self._require_connected():
+ return
+ try:
+ self.hub.send_command("/"+cmd+(" "+arg if arg else ""), room=self.room)
+ except Exception as e:
+ self._local_message("error", "/"+cmd+" failed: "+str(e))
+ return
+
+ self._local_message("error", "Unknown command: /"+cmd+" (try /help)")
+
+ def leave_room(self):
+ try:
+ self.hub.part_room(self.room)
+ except Exception:
+ pass
+ self.display.update_list()
+ self.display.show_placeholder()
+
+
+def _ts_prefix(ts_ms):
+ t = _format_ts(ts_ms) if ts_ms else " "
+ return ("irc_ts", " ["+t+"] ")
+
+
+class _ChatLinkDelegate:
+ def __init__(self, display, hub):
+ self.display = display
+ self.hub = hub
+ self.app = display.app
+ self.last_keypress = 0
+
+ def marked_link(self, target, fields=None):
+ pass
+
+ def micron_released_focus(self):
+ pass
+
+ def handle_link(self, target, fields=None):
+ if target is None:
+ return
+ kind, _, payload = target.partition(":")
+ try:
+ if kind == "room":
+ self._open_room(payload)
+ elif kind == "lxmf":
+ self._open_lxmf(payload)
+ elif kind == "page":
+ self._open_page(payload)
+ except Exception as e:
+ RNS.log("Chat link handler failed: "+str(e), RNS.LOG_ERROR)
+
+ def _open_room(self, room):
+ room = (room or "").strip().lower()
+ if not room:
+ return
+ if room not in self.hub.rooms and self.hub.status == RRCHub.STATUS_CONNECTED:
+ try: self.hub.join_room(room)
+ except Exception: pass
+ self.hub.add_room(room)
+ self.display.update_list()
+ self.display._select_room(None, (self.hub, room))
+
+ def _open_lxmf(self, hash_hex):
+ try:
+ bytes.fromhex(hash_hex)
+ except Exception:
+ return
+ from nomadnet.Directory import DirectoryEntry
+ existing = [c[0] for c in nomadnet.Conversation.conversation_list(self.app)]
+ if hash_hex not in existing:
+ display_name = None
+ try:
+ data = RNS.Identity.recall_app_data(bytes.fromhex(hash_hex))
+ if data is not None:
+ import LXMF
+ display_name = LXMF.display_name_from_app_data(data)
+ except Exception:
+ pass
+ try:
+ self.app.directory.remember(DirectoryEntry(bytes.fromhex(hash_hex), display_name=display_name))
+ except Exception:
+ pass
+ try:
+ nomadnet.Conversation(hash_hex, self.app, initiator=True)
+ except Exception:
+ pass
+ conversations = self.app.ui.main_display.sub_displays.conversations_display
+ conversations.update_conversation_list()
+ conversations.display_conversation(None, hash_hex)
+ self.app.ui.main_display.show_conversations(None)
+
+ def _open_page(self, url):
+ if not url:
+ return
+ self.app.ui.main_display.show_network(None)
+ try:
+ self.app.ui.main_display.sub_displays.network_display.browser.retrieve_url(url)
+ except Exception as e:
+ RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR)
+
+
+def _message_widget(app, hub, m, link_delegate=None):
+ g = app.ui.glyphs
+ own_nick = None
+ try:
+ own_nick = app.rrc.get_nickname()
+ except Exception:
+ pass
+
+ if m.kind == "system":
+ spans, has_links = _body_markup(m.text or "", body_attr="irc_system", own_nick=own_nick)
+ markup = [_ts_prefix(m.ts), ("irc_system", " "+g["arrow_r"]+" ")] + spans
+ return _wrap_text(markup, link_delegate if has_links else None)
+
+ if m.kind == "notice":
+ spans, has_links = _body_markup(m.text or "", body_attr="irc_notice", own_nick=own_nick)
+ markup = [_ts_prefix(m.ts), ("irc_notice", " "+g["info"]+" ")] + spans
+ return _wrap_text(markup, link_delegate if has_links else None)
+
+ if m.kind == "error":
+ spans, has_links = _body_markup(m.text or "", body_attr="irc_error", own_nick=own_nick)
+ markup = [_ts_prefix(m.ts), ("irc_error", " "+g["warning"]+" ")] + spans
+ return _wrap_text(markup, link_delegate if has_links else None)
+
+ own = False
+ try:
+ if hub is not None and m.src is not None and app.identity is not None:
+ own = bytes(m.src) == app.identity.hash
+ except Exception:
+ pass
+
+ if m.nick:
+ sender = m.nick
+ elif isinstance(m.src, (bytes, bytearray)):
+ sender = _short_hash(m.src)
+ else:
+ sender = "?"
+
+ nick_attr = "irc_nick_self" if own else "irc_nick_peer"
+ body = m.text or ""
+ spans, has_links = _body_markup(body, body_attr="body_text", own_nick=None if own else own_nick)
+ markup = [_ts_prefix(m.ts), (nick_attr, "<"+sender+">"), ("body_text", " ")] + spans
+ return _wrap_text(markup, link_delegate if has_links else None)
+
+
+def _wrap_text(markup, link_delegate):
+ if link_delegate is not None:
+ return _ChatLinkableText(markup, align="left", delegate=link_delegate)
+ return urwid.Text(markup)
+
+
+class ChannelsDisplay():
+ list_width = 0.33
+ given_list_width = 36
+
+ def __init__(self, app):
+ self.app = app
+ self.dialog_open = False
+ self.list_widgets = []
+ self.selected_key = None
+ self.current_room_widget = None
+ self.users_visible = True
+
+ self._build_listbox()
+
+ self.list_shortcuts = ChannelsListShortcuts(self.app)
+ self.room_shortcuts = ChannelsRoomShortcuts(self.app)
+ self.shortcuts_display = self.list_shortcuts
+
+ self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top"))
+ self.right = self.placeholder
+
+ self.columns_widget = urwid.Columns(
+ [
+ (ChannelsDisplay.given_list_width, self.listbox),
+ (urwid.WEIGHT, 1, self.right),
+ ],
+ dividechars=0, focus_column=0, box_columns=[0],
+ )
+ self.widget = urwid.WidgetPlaceholder(self.columns_widget)
+
+ self._pending_actions = collections.deque()
+ self._wake_fd = None
+ try:
+ self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending)
+ except Exception:
+ pass
+
+ self._mention_bell_last = {}
+
+ self.app.rrc.set_change_callback(self._on_rrc_change)
+ self.app.rrc.set_message_callback(self._on_rrc_message)
+
+ def start(self):
+ self.update_list()
+
+ def shortcuts(self):
+ try:
+ focus_path = self.columns_widget.get_focus_path()
+ except Exception:
+ focus_path = None
+ if focus_path and focus_path[0] == 1:
+ return self.room_shortcuts
+ return self.list_shortcuts
+
+ def _build_listbox(self):
+ self._compose_list_widgets()
+ self.ilb = IndicativeListBox(
+ self.list_widgets,
+ on_selection_change=lambda a, b: None,
+ initialization_is_selection_change=False,
+ highlight_offFocus="list_off_focus",
+ )
+ self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels")
+ self.listbox.delegate = self
+
+ def _compose_list_widgets(self):
+ widgets = []
+ manager = self.app.rrc
+
+ if not manager.hubs:
+ entry = urwid.AttrMap(urwid.Text("\n No hubs yet. Press Ctrl-N to add one."), "list_unknown")
+ widgets.append(entry)
+ self.list_widgets = widgets
+ return
+
+ g = self.app.ui.glyphs
+ for hub_idx, hub in enumerate(manager.hubs):
+ if hub_idx > 0:
+ spacer = urwid.Text("")
+ spacer.row_kind = "spacer"
+ widgets.append(spacer)
+ if hub.status == RRCHub.STATUS_CONNECTED:
+ status_glyph = g["check"]
+ style = "list_trusted"
+ elif hub.status == RRCHub.STATUS_CONNECTING:
+ status_glyph = g["info"]
+ style = "list_unresponsive"
+ elif hub.status == RRCHub.STATUS_FAILED:
+ status_glyph = g["cross"]
+ style = "list_untrusted"
+ else:
+ status_glyph = " "
+ style = "list_unknown"
+
+ entry = ChannelListEntry(status_glyph+" "+hub.name)
+ urwid.connect_signal(entry, "click", self._select_hub, hub)
+ attr = urwid.AttrMap(entry, style, "list_focus")
+ attr.row_kind = "hub"
+ attr.hub = hub
+ attr.room = None
+ widgets.append(attr)
+
+ for room in sorted(list(hub.rooms | set(hub.messages.keys()))):
+ if not room:
+ continue
+ is_joined = room in hub.rooms
+ mentioned = room in hub.mention_rooms
+ unread = room in hub.unread_rooms
+ if mentioned:
+ marker = g["warning"]
+ room_style = "irc_mention"
+ elif unread:
+ marker = g["unread"]
+ room_style = "list_unresponsive"
+ elif not is_joined:
+ marker = " "
+ room_style = "list_unknown"
+ else:
+ marker = " "
+ room_style = "list_trusted" if hub.status == RRCHub.STATUS_CONNECTED else "list_unknown"
+ room_entry = ChannelListEntry(" "+marker+" #"+room)
+ urwid.connect_signal(room_entry, "click", self._select_room, (hub, room))
+ room_attr = urwid.AttrMap(room_entry, room_style, "list_focus")
+ room_attr.row_kind = "room"
+ room_attr.hub = hub
+ room_attr.room = room
+ widgets.append(room_attr)
+
+ self.list_widgets = widgets
+
+ def update_list(self):
+ prev_key = self.selected_key
+ self._compose_list_widgets()
+ self.ilb = IndicativeListBox(
+ self.list_widgets,
+ on_selection_change=lambda a, b: None,
+ initialization_is_selection_change=False,
+ highlight_offFocus="list_off_focus",
+ )
+ self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels")
+ self.listbox.delegate = self
+
+ options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
+ if not self.dialog_open:
+ self.columns_widget.contents[0] = (self.listbox, options)
+
+ if prev_key is not None:
+ for idx, w in enumerate(self.list_widgets):
+ key = self._row_key(w)
+ if key == prev_key:
+ try: self.ilb.select_item(idx)
+ except Exception: pass
+ break
+
+ self._refresh_active_header()
+ try:
+ self.app.ui.loop.draw_screen()
+ except Exception:
+ pass
+
+ def _row_key(self, w):
+ if not hasattr(w, "row_kind"):
+ return None
+ if w.row_kind == "hub":
+ return ("hub", w.hub.hub_hash, w.hub.dest_name)
+ if w.row_kind == "room":
+ return ("room", w.hub.hub_hash, w.hub.dest_name, w.room)
+ return None
+
+ def _refresh_active_header(self):
+ if self.current_room_widget is not None:
+ try:
+ self.current_room_widget._update_peer_info()
+ except Exception:
+ pass
+ return
+ if self.selected_key and self.selected_key[0] == "hub":
+ for h in self.app.rrc.hubs:
+ if h.hub_hash == self.selected_key[1] and h.dest_name == self.selected_key[2]:
+ self._show_hub_info(h)
+ break
+
+ def _select_hub(self, sender, hub):
+ self.selected_key = ("hub", hub.hub_hash, hub.dest_name)
+ self.app.rrc.set_active(hub, None)
+ self._maybe_autoconnect(hub)
+ self._show_hub_info(hub)
+
+ def _select_room(self, sender, payload):
+ hub, room = payload
+ self.selected_key = ("room", hub.hub_hash, hub.dest_name, room)
+ self.app.rrc.set_active(hub, room)
+ self._maybe_autoconnect(hub)
+ if room not in hub.rooms and hub.status == RRCHub.STATUS_CONNECTED:
+ try: hub.join_room(room)
+ except Exception as e: RNS.log("Auto-join failed: "+str(e), RNS.LOG_ERROR)
+ self._show_room(hub, room)
+
+ def _maybe_autoconnect(self, hub):
+ if hub.status in (RRCHub.STATUS_DISCONNECTED, RRCHub.STATUS_FAILED):
+ try:
+ hub.connect()
+ except Exception as e:
+ RNS.log("Auto-connect failed: "+str(e), RNS.LOG_ERROR)
+
+ def _show_hub_info(self, hub):
+ g = self.app.ui.glyphs
+ status_label = {
+ RRCHub.STATUS_DISCONNECTED: "Disconnected",
+ RRCHub.STATUS_CONNECTING: "Connecting",
+ RRCHub.STATUS_CONNECTED: "Connected",
+ RRCHub.STATUS_FAILED: "Failed",
+ }.get(hub.status, "")
+ status_attr = {
+ RRCHub.STATUS_DISCONNECTED: "list_unknown",
+ RRCHub.STATUS_CONNECTING: "list_unresponsive",
+ RRCHub.STATUS_CONNECTED: "connected_status",
+ RRCHub.STATUS_FAILED: "list_untrusted",
+ }.get(hub.status, "list_unknown")
+
+ lines = [
+ urwid.Text(""),
+ urwid.Text(" Hub : "+hub.name),
+ urwid.Text(" Address : "+hub.hub_hash.hex()),
+ urwid.AttrMap(urwid.Text(" Status : "+status_label+" ("+hub.status_text+")"), status_attr),
+ ]
+ if hub.hub_name:
+ ver = " v"+str(hub.hub_version) if hub.hub_version else ""
+ lines.append(urwid.Text(" Server : "+str(hub.hub_name)+ver))
+
+ ar_glyph = g["check"] if hub.auto_reconnect else g["cross"]
+ ar_attr = "list_trusted" if hub.auto_reconnect else "list_unknown"
+ ar_text = "On" if hub.auto_reconnect else "Off"
+ lines.append(urwid.AttrMap(urwid.Text(" AutoRcn : "+ar_glyph+" "+ar_text+" (Ctrl-T to toggle)"), ar_attr))
+
+ al_glyph = g["check"] if hub.auto_list else g["cross"]
+ al_attr = "list_trusted" if hub.auto_list else "list_unknown"
+ al_text = "On" if hub.auto_list else "Off"
+ lines.append(urwid.AttrMap(urwid.Text(" AutoList : "+al_glyph+" "+al_text+" (Ctrl-E to edit)"), al_attr))
+
+ lines.append(urwid.Divider(g["divider1"]))
+
+ if hub.status == RRCHub.STATUS_CONNECTED:
+ lines.append(urwid.Text(" Connected. Use Ctrl-A to add a room."))
+ elif hub.status == RRCHub.STATUS_CONNECTING:
+ lines.append(urwid.AttrMap(urwid.Text(" Connecting..."), "list_unresponsive"))
+ else:
+ lines.append(urwid.Text(" Use Ctrl-R to connect."))
+
+ if hub.rooms:
+ lines.append(urwid.Divider(g["divider1"]))
+ lines.append(urwid.Text(" Joined rooms:"))
+ for r in sorted(hub.rooms):
+ entry = ChannelListEntry(" #"+r)
+ urwid.connect_signal(entry, "click", self._select_room, (hub, r))
+ lines.append(urwid.AttrMap(entry, "list_trusted", "list_focus"))
+
+ available = sorted(
+ (name, topic) for name, topic in hub.available_rooms.items()
+ if name and name not in hub.rooms
+ )
+ if available:
+ lines.append(urwid.Divider(g["divider1"]))
+ lines.append(urwid.Text(" Available rooms:"))
+ for name, topic in available:
+ label = " #"+name
+ if topic:
+ label += " "+g["arrow_r"]+" "+topic
+ entry = ChannelListEntry(label)
+ urwid.connect_signal(entry, "click", self._select_room, (hub, name))
+ lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus"))
+
+ info = urwid.LineBox(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name)
+ self.current_room_widget = None
+ options = self.columns_widget.options(urwid.WEIGHT, 1)
+ self.columns_widget.contents[1] = (info, options)
+ self.shortcuts_display = self.list_shortcuts
+ self.app.ui.main_display.update_active_shortcuts()
+
+ def show_placeholder(self):
+ self.current_room_widget = None
+ self.selected_key = None
+ options = self.columns_widget.options(urwid.WEIGHT, 1)
+ self.columns_widget.contents[1] = (self.placeholder, options)
+ self.shortcuts_display = self.list_shortcuts
+ self.app.ui.main_display.update_active_shortcuts()
+
+ def _show_room(self, hub, room):
+ widget = RoomWidget(self, hub, room)
+ self.current_room_widget = widget
+ options = self.columns_widget.options(urwid.WEIGHT, 1)
+ self.columns_widget.contents[1] = (widget, options)
+ self.columns_widget.focus_position = 1
+ self.shortcuts_display = self.room_shortcuts
+ self.app.ui.main_display.update_active_shortcuts()
+
+ def _selected_row(self):
+ item = self.ilb.get_selected_item()
+ if item is None:
+ return None
+ return item
+
+ def connect_selected(self):
+ item = self._selected_row()
+ if item is None or not hasattr(item, "hub"):
+ return
+ try:
+ item.hub.connect()
+ except Exception as e:
+ RNS.log("Connect failed: "+str(e), RNS.LOG_ERROR)
+
+ def disconnect_selected(self):
+ item = self._selected_row()
+ if item is None or not hasattr(item, "hub"):
+ return
+ try:
+ item.hub.disconnect()
+ except Exception:
+ pass
+
+ def toggle_auto_reconnect_selected(self):
+ item = self._selected_row()
+ if item is None or not hasattr(item, "hub"):
+ return
+ item.hub.set_auto_reconnect(not item.hub.auto_reconnect)
+ if self.current_room_widget is None:
+ self._show_hub_info(item.hub)
+
+ def remove_selected_dialog(self):
+ item = self._selected_row()
+ if item is None or not hasattr(item, "hub"):
+ return
+ hub = item.hub
+ room = getattr(item, "room", None)
+
+ def confirmed(sender):
+ self.close_dialog()
+ if room is not None:
+ try: hub.part_room(room)
+ except Exception: pass
+ hub.remove_room(room)
+ else:
+ self.app.rrc.remove_hub(hub)
+ self.update_list()
+ self.show_placeholder()
+
+ def dismiss(sender):
+ self.close_dialog()
+
+ if room is not None:
+ prompt = "Leave and remove room\n#"+room+"\non hub "+hub.name+"?"
+ else:
+ prompt = "Remove hub\n"+hub.name+"\nfrom this client?\n All Message history will be discarded."
+
+ dialog = ChannelsDialogLineBox(
+ urwid.Pile([
+ urwid.Text(prompt+"\n", align=urwid.CENTER),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss)),
+ ])
+ ]), title="?"
+ )
+ dialog.delegate = self
+ self._show_dialog_overlay(dialog)
+
+ def new_hub_dialog(self):
+ e_hash = urwid.Edit(caption="Hub address : ", edit_text="")
+ e_name = urwid.Edit(caption="Display name: ", edit_text="")
+ error_text = urwid.Text("")
+
+ def dismiss(sender):
+ self.close_dialog()
+
+ def confirmed(sender):
+ try:
+ hh_text = e_hash.get_edit_text().strip().lower()
+ if hh_text.startswith("0x"):
+ hh_text = hh_text[2:]
+ hh = bytes.fromhex(hh_text)
+ if len(hh) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
+ raise ValueError("Hash length must be "+str(RNS.Reticulum.TRUNCATED_HASHLENGTH//8)+" bytes")
+ nm = e_name.get_edit_text().strip() or None
+ self.app.rrc.add_hub(hh, name=nm)
+ self.close_dialog()
+ self.update_list()
+ except Exception as e:
+ error_text.set_text(("error_text", "Could not add hub: "+str(e)))
+
+ dialog = ChannelsDialogLineBox(
+ urwid.Pile([
+ e_hash,
+ e_name,
+ urwid.Text(""),
+ error_text,
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Add", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)),
+ ])
+ ]), title="New Hub"
+ )
+ dialog.delegate = self
+ self._show_dialog_overlay(dialog)
+
+ def confirm_new_hub_dialog(self, hub_hash, dest_name, room):
+ error_text = urwid.Text("")
+
+ def dismiss(sender):
+ self.close_dialog()
+
+ def confirmed(sender):
+ try:
+ hub = self.app.rrc.add_hub(hub_hash, dest_name=dest_name)
+ self.close_dialog()
+ self.update_list()
+ if room:
+ self._select_room(None, (hub, room))
+ else:
+ self._select_hub(None, hub)
+ except Exception as e:
+ error_text.set_text(("error_text", "Could not add hub: "+str(e)))
+
+ dialog = ChannelsDialogLineBox(
+ urwid.Pile([
+ urwid.Text(""),
+ urwid.Text(" A page is requesting to open an RRC hub."),
+ urwid.Text(""),
+ urwid.Text(" Address : "+hub_hash.hex()),
+ urwid.Text(" Aspect : "+(dest_name or "rrc.hub")),
+ urwid.Text(" Room : "+("#"+room if room else "(none)")),
+ urwid.Text(""),
+ urwid.AttrMap(urwid.Text(
+ " Opening will add this hub to your client,"), "list_unknown"),
+ urwid.AttrMap(urwid.Text(
+ " ,and connect to it, and reveal your identity hash"), "list_unknown"),
+ urwid.AttrMap(urwid.Text(
+ " to the hub operator."), "list_unknown"),
+ urwid.Text(""),
+ error_text,
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Open", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)),
+ ])
+ ]), title="Open RRC hub?"
+ )
+ dialog.delegate = self
+ self._show_dialog_overlay(dialog)
+
+ def edit_hub_dialog(self):
+ item = self._selected_row()
+ if item is None or not hasattr(item, "hub"):
+ return
+ hub = item.hub
+
+ e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "")
+ cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect)
+ cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list)
+ error_text = urwid.Text("")
+
+ def dismiss(sender):
+ self.close_dialog()
+
+ def confirmed(sender):
+ try:
+ nm = e_name.get_edit_text().strip() or hub.name
+ hub.name = nm
+ hub.set_auto_reconnect(cb_autorcn.get_state())
+ hub.set_auto_list(cb_autolist.get_state())
+ self.app.rrc.save()
+ self.close_dialog()
+ self.update_list()
+ if self.selected_key and self.selected_key[0] == "hub" and self.selected_key[1] == hub.hub_hash:
+ self._show_hub_info(hub)
+ except Exception as e:
+ error_text.set_text(("error_text", "Could not save: "+str(e)))
+
+ dialog = ChannelsDialogLineBox(
+ urwid.Pile([
+ urwid.Text(" Address : "+hub.hub_hash.hex()),
+ urwid.Text(" Server : "+(hub.hub_name or "(unknown until connected)")),
+ urwid.Divider(self.app.ui.glyphs["divider1"]),
+ e_name,
+ urwid.Text(""),
+ cb_autorcn,
+ cb_autolist,
+ urwid.Text(""),
+ error_text,
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)),
+ ])
+ ]), title="Edit Hub"
+ )
+ dialog.delegate = self
+ self._show_dialog_overlay(dialog)
+
+ def join_room_dialog(self):
+ item = self._selected_row()
+ hub = None
+ if item is not None and hasattr(item, "hub"):
+ hub = item.hub
+ if hub is None:
+ if self.app.rrc.hubs:
+ hub = self.app.rrc.hubs[0]
+ else:
+ return
+
+ e_room = urwid.Edit(caption="Room : #", edit_text="")
+ e_key = urwid.Edit(caption="Key : ", edit_text="", mask="*")
+ error_text = urwid.Text("")
+
+ key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text(""))
+
+ def update_key_visibility(checkbox, state):
+ if state:
+ key_section_placeholder.original_widget = e_key
+ else:
+ key_section_placeholder.original_widget = urwid.Text("")
+
+ cb_key = urwid.CheckBox("Keyed room (+k)", state=False, on_state_change=update_key_visibility)
+
+ def dismiss(sender):
+ self.close_dialog()
+
+ def confirmed(sender):
+ try:
+ room = e_room.get_edit_text().strip()
+ if not room:
+ raise ValueError("Room name is required")
+ key = e_key.get_edit_text().strip() if cb_key.get_state() else None
+ key = key or None
+ hub.add_room(room)
+ if hub.status == RRCHub.STATUS_CONNECTED:
+ hub.join_room(room, key=key)
+ self.close_dialog()
+ self.update_list()
+ self._select_room(None, (hub, room.lower()))
+ except Exception as e:
+ error_text.set_text(("error_text", "Could not join: "+str(e)))
+
+ dialog = ChannelsDialogLineBox(
+ urwid.Pile([
+ urwid.Text(" Hub : "+hub.name),
+ e_room,
+ cb_key,
+ key_section_placeholder,
+ urwid.Text(""),
+ error_text,
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Join", on_press=confirmed)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss)),
+ ])
+ ]), title="Add Room"
+ )
+ dialog.delegate = self
+ self._show_dialog_overlay(dialog)
+
+ def _show_dialog_overlay(self, dialog):
+ self.dialog_open = True
+ overlay = urwid.Overlay(
+ dialog,
+ self.columns_widget,
+ align=urwid.CENTER,
+ width=(urwid.RELATIVE, 60),
+ min_width=40,
+ valign=urwid.MIDDLE,
+ height=urwid.PACK,
+ )
+ self.widget.original_widget = overlay
+
+ def close_dialog(self):
+ self.dialog_open = False
+ self.widget.original_widget = self.columns_widget
+
+ def _process_pending(self, data):
+ while True:
+ try:
+ action = self._pending_actions.popleft()
+ except IndexError:
+ break
+ try:
+ action()
+ except Exception as e:
+ RNS.log("RRC UI action failed: "+str(e), RNS.LOG_ERROR)
+ return True
+
+ def _wake(self, action):
+ self._pending_actions.append(action)
+ if self._wake_fd is not None:
+ try:
+ os.write(self._wake_fd, b".")
+ return
+ except Exception:
+ pass
+ try:
+ self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None))
+ except Exception:
+ pass
+
+ def _on_rrc_change(self, hub):
+ self._wake(self.update_list)
+
+ def _on_rrc_message(self, hub, msg):
+ def action():
+ is_active = (self.current_room_widget is not None
+ and self.current_room_widget.hub is hub
+ and self.current_room_widget.room == msg.room)
+ if getattr(msg, "mention", False) and not is_active:
+ self._ring_mention_bell(hub, msg.room)
+ if is_active:
+ self.current_room_widget.append_message(msg)
+ self.update_list()
+ self._wake(action)
+
+ def _ring_mention_bell(self, hub, room):
+ key = (hub.hub_hash, room or "")
+ now = time.monotonic()
+ last = self._mention_bell_last.get(key, 0.0)
+ if now - last < 5.0:
+ return
+ self._mention_bell_last[key] = now
+ try:
+ import sys
+ sys.stdout.write("\x07")
+ sys.stdout.flush()
+ except Exception:
+ pass
diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py
index 34d88c3..9f14c22 100644
--- a/nomadnet/ui/textui/Main.py
+++ b/nomadnet/ui/textui/Main.py
@@ -2,6 +2,7 @@ import RNS
from .Network import *
from .Conversations import *
+from .Channels import *
from .Directory import *
from .Config import *
from .Interfaces import *
@@ -15,6 +16,7 @@ class SubDisplays():
self.app = app
self.network_display = NetworkDisplay(self.app)
self.conversations_display = ConversationsDisplay(self.app)
+ self.channels_display = ChannelsDisplay(self.app)
self.directory_display = DirectoryDisplay(self.app)
self.config_display = ConfigDisplay(self.app)
self.interface_display = InterfaceDisplay(self.app)
@@ -103,6 +105,11 @@ class MainDisplay():
self.sub_displays.active_display = self.sub_displays.conversations_display
self.update_active_sub_display()
+ def show_channels(self, user_data):
+ self.sub_displays.active_display = self.sub_displays.channels_display
+ self.update_active_sub_display()
+ self.sub_displays.channels_display.start()
+
def show_directory(self, user_data):
self.sub_displays.active_display = self.sub_displays.directory_display
self.update_active_sub_display()
@@ -181,6 +188,7 @@ class MenuDisplay():
menu_text = (urwid.PACK, self.menu_indicator)
button_network = (11, MenuButton("Network", on_press=handler.show_network))
button_conversations = (17, MenuButton("Conversations", on_press=handler.show_conversations))
+ button_channels = (12, MenuButton("Channels", on_press=handler.show_channels))
button_directory = (13, MenuButton("Directory", on_press=handler.show_directory))
button_map = (7, MenuButton("Map", on_press=handler.show_map))
button_log = (7, MenuButton("Log", on_press=handler.show_log))
@@ -191,9 +199,9 @@ class MenuDisplay():
# buttons = [menu_text, button_conversations, button_node, button_directory, button_map]
if self.app.config["textui"]["hide_guide"]:
- buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_quit]
+ buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit]
else:
- buttons = [menu_text, button_conversations, button_network, button_log, button_interfaces, button_config, button_guide, button_quit]
+ buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit]
columns = MenuColumns(buttons, dividechars=1)
columns.handler = handler
diff --git a/nomadnet/vendor/cbor.py b/nomadnet/vendor/cbor.py
new file mode 100644
index 0000000..6c63096
--- /dev/null
+++ b/nomadnet/vendor/cbor.py
@@ -0,0 +1,430 @@
+# https://github.com/brianolson/cbor_py
+# Copyright 2014-2015 Brian Olson
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import datetime
+import re
+import struct
+from io import BytesIO
+
+
+CBOR_TYPE_MASK = 0xE0 # top 3 bits
+CBOR_INFO_BITS = 0x1F # low 5 bits
+
+
+CBOR_UINT = 0x00
+CBOR_NEGINT = 0x20
+CBOR_BYTES = 0x40
+CBOR_TEXT = 0x60
+CBOR_ARRAY = 0x80
+CBOR_MAP = 0xA0
+CBOR_TAG = 0xC0
+CBOR_7 = 0xE0 # float and other types
+
+CBOR_UINT8_FOLLOWS = 24 # 0x18
+CBOR_UINT16_FOLLOWS = 25 # 0x19
+CBOR_UINT32_FOLLOWS = 26 # 0x1a
+CBOR_UINT64_FOLLOWS = 27 # 0x1b
+CBOR_VAR_FOLLOWS = 31 # 0x1f
+
+CBOR_BREAK = 0xFF
+
+CBOR_FALSE = (CBOR_7 | 20)
+CBOR_TRUE = (CBOR_7 | 21)
+CBOR_NULL = (CBOR_7 | 22)
+CBOR_UNDEFINED = (CBOR_7 | 23) # js 'undefined' value
+
+CBOR_FLOAT16 = (CBOR_7 | 25)
+CBOR_FLOAT32 = (CBOR_7 | 26)
+CBOR_FLOAT64 = (CBOR_7 | 27)
+
+CBOR_TAG_DATE_STRING = 0 # RFC3339
+CBOR_TAG_DATE_ARRAY = 1 # any number, seconds since 1970-01-01T00:00:00 UTC
+CBOR_TAG_BIGNUM = 2 # big-endian byte string follows
+CBOR_TAG_NEGBIGNUM = 3 # big-endian byte string follows
+CBOR_TAG_DECIMAL = 4
+CBOR_TAG_BIGFLOAT = 5
+CBOR_TAG_BASE64URL = 21
+CBOR_TAG_BASE64 = 22
+CBOR_TAG_BASE16 = 23
+CBOR_TAG_CBOR = 24
+
+CBOR_TAG_URI = 32
+CBOR_TAG_REGEX = 35
+CBOR_TAG_MIME = 36
+CBOR_TAG_CBOR_FILEHEADER = 55799 # 0xd9d9f7
+
+_CBOR_TAG_BIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_BIGNUM)
+_CBOR_TAG_NEGBIGNUM_BYTES = struct.pack('B', CBOR_TAG | CBOR_TAG_NEGBIGNUM)
+
+
+def _dumps_bignum_to_bytearray(val):
+ out = []
+ while val > 0:
+ out.insert(0, val & 0x0ff)
+ val = val >> 8
+ return bytes(out)
+
+
+def dumps_int(val):
+ "return bytes representing int val in CBOR"
+ if val >= 0:
+ if val <= 23:
+ return struct.pack('B', val)
+ if val <= 0x0ff:
+ return struct.pack('BB', CBOR_UINT8_FOLLOWS, val)
+ if val <= 0x0ffff:
+ return struct.pack('!BH', CBOR_UINT16_FOLLOWS, val)
+ if val <= 0x0ffffffff:
+ return struct.pack('!BI', CBOR_UINT32_FOLLOWS, val)
+ if val <= 0x0ffffffffffffffff:
+ return struct.pack('!BQ', CBOR_UINT64_FOLLOWS, val)
+ outb = _dumps_bignum_to_bytearray(val)
+ return _CBOR_TAG_BIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb
+ val = -1 - val
+ return _encode_type_num(CBOR_NEGINT, val)
+
+
+def dumps_float(val):
+ return struct.pack("!Bd", CBOR_FLOAT64, val)
+
+
+def _encode_type_num(cbor_type, val):
+ """For some CBOR primary type [0..7] and an auxiliary unsigned number,
+ return CBOR encoded bytes."""
+ assert val >= 0
+ if val <= 23:
+ return struct.pack('B', cbor_type | val)
+ if val <= 0x0ff:
+ return struct.pack('BB', cbor_type | CBOR_UINT8_FOLLOWS, val)
+ if val <= 0x0ffff:
+ return struct.pack('!BH', cbor_type | CBOR_UINT16_FOLLOWS, val)
+ if val <= 0x0ffffffff:
+ return struct.pack('!BI', cbor_type | CBOR_UINT32_FOLLOWS, val)
+ if (((cbor_type == CBOR_NEGINT) and (val <= 0x07fffffffffffffff)) or
+ ((cbor_type != CBOR_NEGINT) and (val <= 0x0ffffffffffffffff))):
+ return struct.pack('!BQ', cbor_type | CBOR_UINT64_FOLLOWS, val)
+ if cbor_type != CBOR_NEGINT:
+ raise Exception("value too big for CBOR unsigned number: {0!r}".format(val))
+ outb = _dumps_bignum_to_bytearray(val)
+ return _CBOR_TAG_NEGBIGNUM_BYTES + _encode_type_num(CBOR_BYTES, len(outb)) + outb
+
+
+def dumps_string(val, is_text=None, is_bytes=None):
+ if isinstance(val, str):
+ val = val.encode('utf8')
+ is_text = True
+ is_bytes = False
+ if (is_bytes) or not (is_text == True):
+ return _encode_type_num(CBOR_BYTES, len(val)) + val
+ return _encode_type_num(CBOR_TEXT, len(val)) + val
+
+
+def dumps_array(arr, sort_keys=False):
+ head = _encode_type_num(CBOR_ARRAY, len(arr))
+ parts = [dumps(x, sort_keys=sort_keys) for x in arr]
+ return head + b''.join(parts)
+
+
+def dumps_dict(d, sort_keys=False):
+ head = _encode_type_num(CBOR_MAP, len(d))
+ parts = [head]
+ if sort_keys:
+ for k in sorted(d.keys()):
+ v = d[k]
+ parts.append(dumps(k, sort_keys=sort_keys))
+ parts.append(dumps(v, sort_keys=sort_keys))
+ else:
+ for k, v in d.items():
+ parts.append(dumps(k, sort_keys=sort_keys))
+ parts.append(dumps(v, sort_keys=sort_keys))
+ return b''.join(parts)
+
+
+def dumps_bool(b):
+ if b:
+ return struct.pack('B', CBOR_TRUE)
+ return struct.pack('B', CBOR_FALSE)
+
+
+def dumps_tag(t, sort_keys=False):
+ return _encode_type_num(CBOR_TAG, t.tag) + dumps(t.value, sort_keys=sort_keys)
+
+
+def dumps(ob, sort_keys=False):
+ if ob is None:
+ return struct.pack('B', CBOR_NULL)
+ if isinstance(ob, bool):
+ return dumps_bool(ob)
+ if isinstance(ob, (str, bytes, bytearray)):
+ if isinstance(ob, bytearray):
+ ob = bytes(ob)
+ return dumps_string(ob)
+ if isinstance(ob, (list, tuple)):
+ return dumps_array(ob, sort_keys=sort_keys)
+ if isinstance(ob, dict):
+ return dumps_dict(ob, sort_keys=sort_keys)
+ if isinstance(ob, float):
+ return dumps_float(ob)
+ if isinstance(ob, int):
+ return dumps_int(ob)
+ if isinstance(ob, Tag):
+ return dumps_tag(ob, sort_keys=sort_keys)
+ raise Exception("don't know how to cbor serialize object of type %s" % type(ob))
+
+
+def dump(obj, fp, sort_keys=False):
+ """obj: Python object to serialize. fp: file-like object capable of .write(bytes)."""
+ blob = dumps(obj, sort_keys=sort_keys)
+ fp.write(blob)
+
+
+class Tag(object):
+ def __init__(self, tag=None, value=None):
+ self.tag = tag
+ self.value = value
+
+ def __repr__(self):
+ return "Tag({0!r}, {1!r})".format(self.tag, self.value)
+
+ def __eq__(self, other):
+ if not isinstance(other, Tag):
+ return False
+ return (self.tag == other.tag) and (self.value == other.value)
+
+ def __hash__(self):
+ return hash((self.tag, self.value)) if not isinstance(self.value, (list, dict, bytearray)) else id(self)
+
+
+def loads(data):
+ """Parse CBOR bytes and return Python objects."""
+ if data is None:
+ raise ValueError("got None for buffer to decode in loads")
+ if isinstance(data, (bytes, bytearray, memoryview)):
+ fp = BytesIO(bytes(data))
+ else:
+ fp = data
+ return _loads(fp)[0]
+
+
+def load(fp):
+ """Parse and return object from fp, a file-like object supporting .read(n)."""
+ return _loads(fp)[0]
+
+
+_MAX_DEPTH = 100
+
+
+def _tag_aux(fp, tb):
+ bytes_read = 1
+ tag = tb & CBOR_TYPE_MASK
+ tag_aux = tb & CBOR_INFO_BITS
+ if tag_aux <= 23:
+ aux = tag_aux
+ elif tag_aux == CBOR_UINT8_FOLLOWS:
+ data = fp.read(1)
+ aux = struct.unpack_from("!B", data, 0)[0]
+ bytes_read += 1
+ elif tag_aux == CBOR_UINT16_FOLLOWS:
+ data = fp.read(2)
+ aux = struct.unpack_from("!H", data, 0)[0]
+ bytes_read += 2
+ elif tag_aux == CBOR_UINT32_FOLLOWS:
+ data = fp.read(4)
+ aux = struct.unpack_from("!I", data, 0)[0]
+ bytes_read += 4
+ elif tag_aux == CBOR_UINT64_FOLLOWS:
+ data = fp.read(8)
+ aux = struct.unpack_from("!Q", data, 0)[0]
+ bytes_read += 8
+ else:
+ assert tag_aux == CBOR_VAR_FOLLOWS, "bogus tag {0:02x}".format(tb)
+ aux = None
+
+ return tag, tag_aux, aux, bytes_read
+
+
+def _read_byte(fp):
+ tb = fp.read(1)
+ if len(tb) == 0:
+ raise EOFError()
+ return tb[0]
+
+
+def _loads_var_array(fp, limit, depth, returntags, bytes_read):
+ ob = []
+ tb = _read_byte(fp)
+ while tb != CBOR_BREAK:
+ (subob, sub_len) = _loads_tb(fp, tb, limit, depth, returntags)
+ bytes_read += 1 + sub_len
+ ob.append(subob)
+ tb = _read_byte(fp)
+ return (ob, bytes_read + 1)
+
+
+def _loads_var_map(fp, limit, depth, returntags, bytes_read):
+ ob = {}
+ tb = _read_byte(fp)
+ while tb != CBOR_BREAK:
+ (subk, sub_len) = _loads_tb(fp, tb, limit, depth, returntags)
+ bytes_read += 1 + sub_len
+ (subv, sub_len) = _loads(fp, limit, depth, returntags)
+ bytes_read += sub_len
+ ob[subk] = subv
+ tb = _read_byte(fp)
+ return (ob, bytes_read + 1)
+
+
+def _loads_array(fp, limit, depth, returntags, aux, bytes_read):
+ ob = []
+ for _ in range(aux):
+ subob, subpos = _loads(fp, limit, depth, returntags)
+ bytes_read += subpos
+ ob.append(subob)
+ return ob, bytes_read
+
+
+def _loads_map(fp, limit, depth, returntags, aux, bytes_read):
+ ob = {}
+ for _ in range(aux):
+ subk, subpos = _loads(fp, limit, depth, returntags)
+ bytes_read += subpos
+ subv, subpos = _loads(fp, limit, depth, returntags)
+ bytes_read += subpos
+ ob[subk] = subv
+ return ob, bytes_read
+
+
+def _loads(fp, limit=None, depth=0, returntags=False):
+ "return (object, bytes read)"
+ if depth > _MAX_DEPTH:
+ raise Exception("hit CBOR loads recursion depth limit")
+ tb = _read_byte(fp)
+ return _loads_tb(fp, tb, limit, depth, returntags)
+
+
+def _loads_tb(fp, tb, limit=None, depth=0, returntags=False):
+ # Some special cases of CBOR_7 best handled by special struct.unpack logic here
+ if tb == CBOR_FLOAT16:
+ data = fp.read(2)
+ hibyte, lowbyte = struct.unpack_from("BB", data, 0)
+ exp = (hibyte >> 2) & 0x1F
+ mant = ((hibyte & 0x03) << 8) | lowbyte
+ if exp == 0:
+ val = mant * (2.0 ** -24)
+ elif exp == 31:
+ val = float('Inf') if mant == 0 else float('NaN')
+ else:
+ val = (mant + 1024.0) * (2 ** (exp - 25))
+ if hibyte & 0x80:
+ val = -1.0 * val
+ return (val, 3)
+ elif tb == CBOR_FLOAT32:
+ data = fp.read(4)
+ pf = struct.unpack_from("!f", data, 0)
+ return (pf[0], 5)
+ elif tb == CBOR_FLOAT64:
+ data = fp.read(8)
+ pf = struct.unpack_from("!d", data, 0)
+ return (pf[0], 9)
+
+ tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb)
+
+ if tag == CBOR_UINT:
+ return (aux, bytes_read)
+ elif tag == CBOR_NEGINT:
+ return (-1 - aux, bytes_read)
+ elif tag == CBOR_BYTES:
+ ob, subpos = loads_bytes(fp, aux)
+ return (ob, bytes_read + subpos)
+ elif tag == CBOR_TEXT:
+ raw, subpos = loads_bytes(fp, aux, btag=CBOR_TEXT)
+ ob = raw.decode('utf8')
+ return (ob, bytes_read + subpos)
+ elif tag == CBOR_ARRAY:
+ if aux is None:
+ return _loads_var_array(fp, limit, depth, returntags, bytes_read)
+ return _loads_array(fp, limit, depth, returntags, aux, bytes_read)
+ elif tag == CBOR_MAP:
+ if aux is None:
+ return _loads_var_map(fp, limit, depth, returntags, bytes_read)
+ return _loads_map(fp, limit, depth, returntags, aux, bytes_read)
+ elif tag == CBOR_TAG:
+ ob, subpos = _loads(fp, limit, depth + 1, returntags)
+ bytes_read += subpos
+ if returntags:
+ ob = Tag(aux, ob)
+ else:
+ ob = tagify(ob, aux)
+ return ob, bytes_read
+ elif tag == CBOR_7:
+ if tb == CBOR_TRUE:
+ return (True, bytes_read)
+ if tb == CBOR_FALSE:
+ return (False, bytes_read)
+ if tb == CBOR_NULL:
+ return (None, bytes_read)
+ if tb == CBOR_UNDEFINED:
+ return (None, bytes_read)
+ raise ValueError("unknown cbor tag 7 byte: {:02x}".format(tb))
+
+
+def loads_bytes(fp, aux, btag=CBOR_BYTES):
+ if aux is not None:
+ ob = fp.read(aux)
+ return (ob, aux)
+ chunklist = []
+ total_bytes_read = 0
+ while True:
+ tb = fp.read(1)[0]
+ if tb == CBOR_BREAK:
+ total_bytes_read += 1
+ break
+ tag, tag_aux, aux, bytes_read = _tag_aux(fp, tb)
+ assert tag == btag, 'variable length value contains unexpected component'
+ ob = fp.read(aux)
+ chunklist.append(ob)
+ total_bytes_read += bytes_read + aux
+ return (b''.join(chunklist), total_bytes_read)
+
+
+def _bytes_to_biguint(bs):
+ out = 0
+ for ch in bs:
+ out = out << 8
+ out = out | ch
+ return out
+
+
+def tagify(ob, aux):
+ if aux == CBOR_TAG_DATE_STRING:
+ # RFC3339 date string parsing not implemented; return as Tag.
+ return Tag(aux, ob)
+ if aux == CBOR_TAG_DATE_ARRAY:
+ return datetime.datetime.fromtimestamp(ob, tz=datetime.timezone.utc)
+ if aux == CBOR_TAG_BIGNUM:
+ return _bytes_to_biguint(ob)
+ if aux == CBOR_TAG_NEGBIGNUM:
+ return -1 - _bytes_to_biguint(ob)
+ if aux == CBOR_TAG_REGEX:
+ return re.compile(ob)
+ return Tag(aux, ob)
+
+
+def encode(obj, sort_keys=False):
+ return dumps(obj, sort_keys=sort_keys)
+
+
+def decode(data):
+ return loads(data)
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────